diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3d00be6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,85 @@ +# Checks this repo's own files: the reusable workflows and templates it hands to +# plugin repos, and the Playwright harness those workflows check out. Nothing +# here needs COSMOS running - the harness is exercised for real by each plugin's +# Playwright run - so this catches the mistakes that are cheap to catch early: +# a workflow that doesn't parse, a template whose JSON is broken, a spec that +# doesn't compile. +# +# Third party actions are pinned to a full commit SHA, because a tag can be moved +# to point at different code. The comment after each pin records the tag it was. + +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +defaults: + run: + shell: bash + +jobs: + workflows: + name: Lint workflows + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Run actionlint + env: + ACTIONLINT_VERSION: 1.7.12 + run: | + set -euo pipefail + bash <(curl -fsSL "https://raw.githubusercontent.com/rhysd/actionlint/v${ACTIONLINT_VERSION}/scripts/download-actionlint.bash") "$ACTIONLINT_VERSION" + # The templates are workflows too, just ones GitHub copies into plugin + # repos rather than runs here + ./actionlint -color .github/workflows/*.yml workflow-templates/*.yml + + - name: Validate template metadata + run: | + set -euo pipefail + for file in workflow-templates/*.properties.json; do + jq empty "$file" + done + # GitHub only offers a template that has a properties file beside it + for file in workflow-templates/*.yml; do + if [ ! -f "${file%.yml}.properties.json" ]; then + echo "::error file=$file::Missing ${file%.yml}.properties.json" + exit 1 + fi + done + + playwright-harness: + name: Check Playwright harness + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: playwright-plugin + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + + - name: Install dependencies + run: npm ci --no-audit --no-fund + + - name: Typecheck + run: npm run typecheck + + # Loads the config and every spec the way a real run would, so a broken + # import or project wiring fails here. No browser is needed to list. + - name: List tests + env: + PLUGIN_GEM: /nonexistent/openc3-cosmos-example-1.0.0.gem + run: npm run list diff --git a/.github/workflows/copy-release-to-forgejo-reusable.yml b/.github/workflows/copy-release-to-forgejo-reusable.yml index bcca093..5adbafd 100644 --- a/.github/workflows/copy-release-to-forgejo-reusable.yml +++ b/.github/workflows/copy-release-to-forgejo-reusable.yml @@ -37,7 +37,7 @@ jobs: FORGEJO_REPO="openc3/${{ github.event.repository.name }}" echo "No forgejo_repo provided, defaulting to: $FORGEJO_REPO" fi - echo "forgejo_repo=$FORGEJO_REPO" >> $GITHUB_OUTPUT + echo "forgejo_repo=$FORGEJO_REPO" >> "$GITHUB_OUTPUT" - name: Fetch and Mirror Release env: diff --git a/.github/workflows/plugin-playwright-reusable.yml b/.github/workflows/plugin-playwright-reusable.yml new file mode 100644 index 0000000..6cd901e --- /dev/null +++ b/.github/workflows/plugin-playwright-reusable.yml @@ -0,0 +1,285 @@ +# Builds a COSMOS plugin and installs it into a real COSMOS with Playwright. +# +# Starts the most recent COSMOS Core release in Docker using the cosmos-project +# compose stack, builds the plugin gem from the calling repo, then drives the +# Admin tool the way an operator would: Install From File, submit the variables +# dialog, and wait for the install process to report Complete. This catches the +# failures unit tests can't - a plugin.txt that doesn't parse, a target that +# doesn't build, a microservice that won't start. +# +# Plugins that register a COSMOS tool additionally get each tool page opened and +# checked for browser console errors. That is discovered from the COSMOS API +# after install, so plugins without a tool skip it with nothing to configure. +# +# The Playwright harness lives in this repo under playwright-plugin/, so the +# calling plugin needs no test files of its own. +# +# A frontend needs nothing declared either: a plugin with a package.json gets +# pnpm and `pnpm install`, and `rake build` is what runs `pnpm run build`. +# +# Third party actions are pinned to a full commit SHA, because a tag can be moved +# to point at different code. The comment after each pin records the tag it was. + +name: Plugin Playwright (Reusable) + +on: + workflow_call: + inputs: + plugin_directory: + description: 'Directory containing the plugin (e.g., openc3-cosmos-demo-plugin)' + required: false + type: string + default: '.' + version: + description: 'Version to build the plugin gem with' + required: false + type: string + default: '1.0.0' + cosmos_version: + description: 'COSMOS version to run, or latest to resolve the newest release' + required: false + type: string + default: 'latest' + expected_targets: + description: 'Space separated target names the plugin should define, checked after install' + required: false + type: string + default: '' + install_demo: + description: 'Install the COSMOS demo, which plugins that reference INST targets need' + required: false + type: boolean + default: false + is_tool: + description: 'Whether the plugin is expected to register a COSMOS tool, which makes registering none a failure rather than a skip' + required: false + type: boolean + default: false + console_ignore: + description: 'Newline separated regexes for browser console errors to allow on the tool pages' + required: false + type: string + default: '' + ruby_version: + description: 'Ruby version used to build the gem' + required: false + type: string + default: '3.4' + workflows_ref: + description: 'Ref of OpenC3/.github to take the Playwright harness from' + required: false + type: string + default: 'main' + +permissions: + contents: read + +defaults: + run: + shell: bash + +jobs: + playwright: + name: Install plugin in COSMOS + runs-on: ubuntu-latest + # Checkout, the gem build and starting COSMOS can take 20 minutes before + # Playwright starts, and Playwright's own globalTimeout is 40 + timeout-minutes: 75 + steps: + - name: Checkout plugin + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # The harness lives alongside this workflow rather than in every plugin + - name: Checkout Playwright harness + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: OpenC3/.github + ref: ${{ inputs.workflows_ref }} + path: .openc3-shared + + - name: Resolve COSMOS version + id: cosmos + env: + GH_TOKEN: ${{ github.token }} + REQUESTED: ${{ inputs.cosmos_version }} + run: | + set -euo pipefail + if [ "$REQUESTED" = "latest" ]; then + # Release tags are v7.4.0, the image tag is 7.4.0 + tag=$(gh api repos/OpenC3/cosmos/releases/latest --jq .tag_name) + version="${tag#v}" + else + version="${REQUESTED#v}" + fi + if [ -z "$version" ]; then + echo "::error::Could not resolve a COSMOS version" + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "Running COSMOS $version" + echo "COSMOS version: \`$version\`" >> "$GITHUB_STEP_SUMMARY" + + - name: Checkout cosmos-project + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: OpenC3/cosmos-project + path: .cosmos + + - name: Configure the COSMOS stack + working-directory: .cosmos + env: + COSMOS_VERSION: ${{ steps.cosmos.outputs.version }} + INSTALL_DEMO: ${{ inputs.install_demo }} + run: | + set -euo pipefail + # .env ships the defaults; .env.local is loaded after it and wins, so + # the pinned upstream file stays untouched + # The empty string is the one value that disables the demo on every + # COSMOS. Current init.sh reads OPENC3_DEMO through flag_enabled, where + # empty, 0 and false are all off, but older releases tested it with + # `[ ! -z $OPENC3_DEMO ]`, where the non-empty string 0 counts as ON. + demo= + if [ "$INSTALL_DEMO" = "true" ]; then demo=1; fi + cat > .env.local <> "$GITHUB_OUTPUT" + echo "Frontend: \`package.json\` found, building with pnpm" >> "$GITHUB_STEP_SUMMARY" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "Frontend: no \`package.json\`, nothing to build" >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Install pnpm + if: ${{ steps.frontend.outputs.present == 'true' }} + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + with: + package_json_file: ${{ inputs.plugin_directory }}/package.json + + - name: Install frontend dependencies + if: ${{ steps.frontend.outputs.present == 'true' }} + working-directory: ${{ inputs.plugin_directory }} + run: pnpm install --frozen-lockfile + + # openc3cli is what `rake build` shells out to for validation + - name: Install openc3 gem + run: gem install --no-document openc3 + + # There is no separate `pnpm run build` step because this one covers it: + # the plugin's Rakefile runs the frontend build itself when the plugin has + # a package.json + - name: Build plugin gem + id: gem + working-directory: ${{ inputs.plugin_directory }} + env: + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + # Some plugins have a built gem committed. Clear any out of the way so + # the gem we install is unambiguously the one we just built, and so a + # stale committed gem can't be what gets tested. + rm -f ./*.gem + rake build VERSION="$VERSION" + shopt -s nullglob + gems=(./*.gem) + if [ ${#gems[@]} -ne 1 ]; then + echo "::error::Expected exactly one gem after the build, found ${#gems[@]}" + ls -la ./*.gem || true + exit 1 + fi + gem_path=$(cd "$(dirname "${gems[0]}")" && pwd)/$(basename "${gems[0]}") + echo "path=$gem_path" >> "$GITHUB_OUTPUT" + echo "Built \`$(basename "$gem_path")\`" >> "$GITHUB_STEP_SUMMARY" + + - name: Start COSMOS + working-directory: .cosmos + run: ./openc3.sh run + + - name: Wait for COSMOS to come up + run: | + set -euo pipefail + # The Playwright setup project waits for the tools to register; this + # only waits for the front door, and fails fast on a crash loop + for attempt in $(seq 1 60); do + if curl -fsS -o /dev/null http://localhost:2900; then + echo "COSMOS is answering after ${attempt} attempts" + exit 0 + fi + if docker ps --format '{{.Names}} {{.Status}}' | grep -q Restarting; then + echo "::error::A COSMOS container is restarting" + docker ps + exit 1 + fi + sleep 10 + done + echo "::error::COSMOS did not answer on http://localhost:2900" + docker ps + exit 1 + + - name: Install Playwright + working-directory: .openc3-shared/playwright-plugin + run: | + set -euo pipefail + npm ci --no-audit --no-fund + npx playwright install --with-deps chromium + + - name: Install the plugin with Playwright + working-directory: .openc3-shared/playwright-plugin + env: + CI: true + PLUGIN_GEM: ${{ steps.gem.outputs.path }} + EXPECTED_TARGETS: ${{ inputs.expected_targets }} + # Tool pages are only checked when the plugin registers a tool, which + # the spec discovers from the COSMOS API after install + IS_TOOL: ${{ inputs.is_tool }} + CONSOLE_IGNORE: ${{ inputs.console_ignore }} + run: npx playwright test --project=chromium + + - name: COSMOS logs + if: ${{ failure() }} + working-directory: .cosmos + run: | + docker ps -a + for service in openc3-cosmos-init openc3-operator openc3-cosmos-cmd-tlm-api openc3-cosmos-script-runner-api; do + echo "::group::$service" + docker compose logs --tail 200 "$service" || true + echo "::endgroup::" + done + + - name: Upload Playwright artifacts + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: plugin-playwright + path: .openc3-shared/playwright-plugin/test-results + retention-days: 30 + if-no-files-found: ignore + + - name: Stop COSMOS + if: ${{ always() }} + working-directory: .cosmos + run: ./openc3.sh stop || true diff --git a/.github/workflows/plugin-unit-tests-reusable.yml b/.github/workflows/plugin-unit-tests-reusable.yml new file mode 100644 index 0000000..72d83c9 --- /dev/null +++ b/.github/workflows/plugin-unit-tests-reusable.yml @@ -0,0 +1,213 @@ +# Generic unit tests for COSMOS plugins. +# +# Runs Python tests found in tests/ or test/ and Ruby specs found in spec/ or +# specs/. A plugin with only one kind of test skips the other job, and a plugin +# with neither passes with a warning unless require-tests is set. +# +# Dependencies are picked up automatically: requirements.txt for the plugin's own +# imports, /requirements.txt for test only ones, and a Gemfile for Ruby +# when there is one, otherwise the openc3 and rspec gems. There is nothing to +# configure per plugin unless it lives in a subdirectory of a monorepo, in which +# case set plugin_directory. +# +# Third party actions are pinned to a full commit SHA, because a tag can be moved +# to point at different code. The comment after each pin records the tag it was. + +name: Plugin Unit Tests (Reusable) + +on: + workflow_call: + inputs: + plugin_directory: + description: 'Directory containing the plugin (e.g., openc3-cosmos-demo-plugin)' + required: false + type: string + default: '.' + python_version: + description: 'Python version used for the Python tests' + required: false + type: string + default: '3.12' + ruby_version: + description: 'Ruby version used for the Ruby specs' + required: false + type: string + default: '3.4' + require_tests: + description: 'Fail when the plugin has no tests at all, rather than passing quietly' + required: false + type: boolean + default: false + +permissions: + contents: read + +defaults: + run: + shell: bash + +jobs: + # Work out which kinds of tests this plugin actually has, so the language jobs + # below skip rather than fail on a plugin which has none of that kind. + detect: + name: Detect tests + runs-on: ubuntu-latest + outputs: + python-dir: ${{ steps.detect.outputs.python-dir }} + ruby-dir: ${{ steps.detect.outputs.ruby-dir }} + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Validate plugin directory exists + run: | + if [ ! -d "${{ inputs.plugin_directory }}" ]; then + echo "Error: Plugin directory '${{ inputs.plugin_directory }}' does not exist" + exit 1 + fi + + - name: Detect test directories + id: detect + working-directory: ${{ inputs.plugin_directory }} + env: + REQUIRE_TESTS: ${{ inputs.require_tests }} + run: | + set -euo pipefail + + # A directory only counts when it actually holds test files, so an empty + # or unrelated tests/ directory doesn't make the job fail with no tests + first_dir_containing() { + local pattern=$1 + shift + for candidate in "$@"; do + if [ -d "$candidate" ] && [ -n "$(find "$candidate" -name "$pattern" -print -quit)" ]; then + echo "$candidate" + return + fi + done + } + + python_dir=$(first_dir_containing 'test_*.py' tests test) + if [ -z "$python_dir" ]; then + python_dir=$(first_dir_containing '*_test.py' tests test) + fi + ruby_dir=$(first_dir_containing '*_spec.rb' spec specs) + + echo "python-dir=$python_dir" >> "$GITHUB_OUTPUT" + echo "ruby-dir=$ruby_dir" >> "$GITHUB_OUTPUT" + + { + echo "## Detected tests" + echo "- Python: ${python_dir:-none}" + echo "- Ruby: ${ruby_dir:-none}" + } >> "$GITHUB_STEP_SUMMARY" + + if [ -z "$python_dir" ] && [ -z "$ruby_dir" ]; then + if [ "${REQUIRE_TESTS:-false}" = "true" ]; then + echo "::error::No tests found in tests/, test/, spec/ or specs/" + exit 1 + fi + echo "::warning::No tests found in tests/, test/, spec/ or specs/" + fi + + python: + name: Python tests + needs: detect + if: needs.detect.outputs.python-dir != '' + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install uv + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + with: + enable-cache: true + python-version: ${{ inputs.python_version }} + + - name: Create virtual environment + working-directory: ${{ inputs.plugin_directory }} + run: uv venv .venv + + - name: Install dependencies + working-directory: ${{ inputs.plugin_directory }} + env: + TEST_DIR: ${{ needs.detect.outputs.python-dir }} + run: | + set -euo pipefail + uv pip install --python .venv/bin/python pytest coverage openc3 + # The plugin's own runtime dependencies, which the code under test imports + if [ -f requirements.txt ]; then + uv pip install --python .venv/bin/python -r requirements.txt + fi + # Dependencies needed only by the tests + if [ -f "$TEST_DIR/requirements.txt" ]; then + uv pip install --python .venv/bin/python -r "$TEST_DIR/requirements.txt" + fi + + - name: Run tests + working-directory: ${{ inputs.plugin_directory }} + env: + TEST_DIR: ${{ needs.detect.outputs.python-dir }} + run: .venv/bin/python -m coverage run -m pytest "$TEST_DIR" -v + + - name: Coverage + # Report coverage even when a test failed, but don't add a second, more + # confusing failure when the run never got far enough to record any + if: ${{ !cancelled() }} + working-directory: ${{ inputs.plugin_directory }} + run: | + set -euo pipefail + if [ ! -f .coverage ]; then + echo "No coverage data recorded" >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + # coverage report exits non-zero when there is nothing measured and + # when a plugin configures fail_under, neither of which should turn + # into a second failure on top of whatever the test run reported + { + echo "## Python coverage" + echo '```' + .venv/bin/python -m coverage report || true + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + ruby: + name: Ruby specs + needs: detect + if: needs.detect.outputs.ruby-dir != '' + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Ruby + uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 + with: + ruby-version: ${{ inputs.ruby_version }} + working-directory: ${{ inputs.plugin_directory }} + # Caching bundler needs a lockfile to key on, and most plugins have no + # Gemfile at all, in which case the gems are installed directly below + bundler-cache: ${{ hashFiles(format('{0}/Gemfile.lock', inputs.plugin_directory)) != '' }} + + - name: Install dependencies + working-directory: ${{ inputs.plugin_directory }} + run: | + set -euo pipefail + if [ -f Gemfile ]; then + bundle install --jobs 4 --retry 3 + else + gem install --no-document rspec openc3 + fi + + - name: Run specs + working-directory: ${{ inputs.plugin_directory }} + env: + SPEC_DIR: ${{ needs.detect.outputs.ruby-dir }} + run: | + set -euo pipefail + if [ -f Gemfile ]; then + bundle exec rspec "$SPEC_DIR" --format documentation + else + rspec "$SPEC_DIR" --format documentation + fi diff --git a/.github/workflows/release-cosmos-plugin-reusable.yml b/.github/workflows/release-cosmos-plugin-reusable.yml index c45a330..29d508d 100644 --- a/.github/workflows/release-cosmos-plugin-reusable.yml +++ b/.github/workflows/release-cosmos-plugin-reusable.yml @@ -12,24 +12,32 @@ on: required: false type: boolean default: true + deploy_to_rubygems: + description: 'Publish the gem to RubyGems (requires the RUBYGEMS_API_KEY secret)' + required: false + type: boolean + default: false plugin_directory: description: 'Directory containing the plugin (e.g., openc3-cosmos-demo-plugin)' required: false type: string default: '.' is_tool: - description: 'Whether this is a tool (requires frontend build)' + description: 'Assert the plugin has a frontend, so a missing package.json fails here instead of publishing a gem with no tool in it. The frontend is built whenever a package.json is present, with or without this.' required: false type: boolean default: false frozen_lockfile: - description: 'Use pnpm --frozen-lockfile (ignored when is_tool is false)' + description: 'Use pnpm --frozen-lockfile (ignored when the plugin has no package.json)' required: false type: boolean default: true secrets: OPENC3_API_TOKEN: required: true + RUBYGEMS_API_KEY: + description: 'RubyGems API key, only needed when deploy_to_rubygems is true' + required: false jobs: release: @@ -53,6 +61,21 @@ jobs: exit 1 fi + # Checked up front rather than at the push step, which runs after the tag + # has been pushed and the App Store upload has already happened + - name: Validate RubyGems credentials + if: ${{ inputs.deploy_to_rubygems }} + env: + GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }} + run: | + if [ -z "$GEM_HOST_API_KEY" ]; then + echo "Error: deploy_to_rubygems is true but the RUBYGEMS_API_KEY secret is empty" + echo "Add the key to the repo secrets and pass it through, e.g." + echo " secrets:" + echo ' RUBYGEMS_API_KEY: $''{{ secrets.RUBYGEMS_API_KEY }}' + exit 1 + fi + - name: Validate package.json exists if: ${{ inputs.is_tool }} run: | @@ -62,8 +85,25 @@ jobs: exit 1 fi + # The presence of a package.json - not an input - is what decides whether + # the frontend gets built. Gating that on is_tool meant a plugin that grew + # a tool or a widget and whose caller never added the flag published a gem + # with the frontend silently missing. is_tool is now only the assertion + # above: say it when you want a missing package.json to fail the release. + - name: Detect frontend + id: frontend + working-directory: ${{ inputs.plugin_directory }} + run: | + if [ -f package.json ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + echo "Frontend detected, building with pnpm" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "No package.json, nothing to build" + fi + - name: Validate packageManager field - if: ${{ inputs.is_tool }} + if: ${{ steps.frontend.outputs.present == 'true' }} run: | cat "${{ inputs.plugin_directory }}/package.json" if ! grep -q '"packageManager"' "${{ inputs.plugin_directory }}/package.json"; then @@ -77,32 +117,36 @@ jobs: id: plugin_info working-directory: ${{ inputs.plugin_directory }} run: | - GEMSPEC_FILE=$(ls *.gemspec | head -n1) - if [ -z "$GEMSPEC_FILE" ]; then + shopt -s nullglob + GEMSPEC_FILES=(./*.gemspec) + GEMSPEC_FILE=$(basename "${GEMSPEC_FILES[0]:-}") + if [ ${#GEMSPEC_FILES[@]} -eq 0 ]; then echo "Error: No gemspec file found in ${{ inputs.plugin_directory }}" exit 1 fi PLUGIN_NAME="${GEMSPEC_FILE%.gemspec}" - echo "plugin_name=$PLUGIN_NAME" >> $GITHUB_OUTPUT + echo "plugin_name=$PLUGIN_NAME" >> "$GITHUB_OUTPUT" echo "Plugin name: $PLUGIN_NAME" - name: Set up Node.js - if: ${{ inputs.is_tool }} + if: ${{ steps.frontend.outputs.present == 'true' }} uses: actions/setup-node@v6 with: node-version: '24' - name: Install pnpm - if: ${{ inputs.is_tool }} + if: ${{ steps.frontend.outputs.present == 'true' }} uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v5.0.0 + with: + package_json_file: ${{ inputs.plugin_directory }}/package.json - name: Install frontend dependencies - if: ${{ inputs.is_tool }} + if: ${{ steps.frontend.outputs.present == 'true' }} working-directory: ${{ inputs.plugin_directory }} run: pnpm install ${{ inputs.frozen_lockfile && '--frozen-lockfile' || '' }} - name: Build frontend - if: ${{ inputs.is_tool }} + if: ${{ steps.frontend.outputs.present == 'true' }} working-directory: ${{ inputs.plugin_directory }} run: pnpm run build @@ -113,22 +157,36 @@ jobs: - name: Build gem working-directory: ${{ inputs.plugin_directory }} - run: rake build VERSION=${{ inputs.version }} + run: | + # Some plugins have a built gem committed at the plugin root. Clear any + # out first so the glob below can only match what this build produced; + # otherwise a stale gem sorts first and gets published in place of the + # version being released. Only the plugin root is touched, so gems + # vendored in subdirectories are left alone. + rm -f ./*.gem + rake build VERSION=${{ inputs.version }} - name: Find and set gem filename id: gem_file working-directory: ${{ inputs.plugin_directory }} run: | - GEM_FILE=$(ls ${{ steps.plugin_info.outputs.plugin_name }}-*.gem) - echo "gem_filename=$GEM_FILE" >> $GITHUB_OUTPUT + shopt -s nullglob + GEM_FILES=(./${{ steps.plugin_info.outputs.plugin_name }}-*.gem) + if [ ${#GEM_FILES[@]} -ne 1 ]; then + echo "Error: Expected exactly one gem after the build, found ${#GEM_FILES[@]}" + ls -la ./*.gem || true + exit 1 + fi + GEM_FILE=$(basename "${GEM_FILES[0]}") + echo "gem_filename=$GEM_FILE" >> "$GITHUB_OUTPUT" echo "Found gem file: $GEM_FILE" - name: Calculate checksum id: checksum working-directory: ${{ inputs.plugin_directory }} run: | - CHECKSUM=$(shasum -a 256 ${{ steps.gem_file.outputs.gem_filename }} | cut -d' ' -f1) - echo "checksum=$CHECKSUM" >> $GITHUB_OUTPUT + CHECKSUM=$(shasum -a 256 "${{ steps.gem_file.outputs.gem_filename }}" | cut -d' ' -f1) + echo "checksum=$CHECKSUM" >> "$GITHUB_OUTPUT" echo "Checksum: $CHECKSUM" - name: Create and push git tag @@ -142,7 +200,7 @@ jobs: fi git tag -a "$TAG_NAME" -m "Release ${{ steps.plugin_info.outputs.plugin_name }} version ${{ inputs.version }}" git push origin "$TAG_NAME" - echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT + echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT" id: tag_info - name: Upload to OpenC3 App Store @@ -169,6 +227,17 @@ jobs: echo "Plugin version ${{ inputs.version }} successfully uploaded to OpenC3 App Store!" + # gem push reads the credential from GEM_HOST_API_KEY, so nothing is + # written to ~/.gem/credentials on the runner + - name: Publish to RubyGems + if: ${{ inputs.deploy_to_rubygems }} + working-directory: ${{ inputs.plugin_directory }} + env: + GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }} + run: | + gem push "${{ steps.gem_file.outputs.gem_filename }}" + echo "Plugin version ${{ inputs.version }} successfully pushed to RubyGems!" + - name: Create GitHub Release id: create_release uses: actions/create-release@v1 diff --git a/playwright-plugin/.gitignore b/playwright-plugin/.gitignore new file mode 100644 index 0000000..682cece --- /dev/null +++ b/playwright-plugin/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +test-results/ +playwright-report/ +storageState.json diff --git a/playwright-plugin/package-lock.json b/playwright-plugin/package-lock.json new file mode 100644 index 0000000..c380d74 --- /dev/null +++ b/playwright-plugin/package-lock.json @@ -0,0 +1,470 @@ +{ + "name": "openc3-plugin-playwright", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "openc3-plugin-playwright", + "version": "1.0.0", + "dependencies": { + "@playwright/test": "1.62.1" + }, + "devDependencies": { + "@types/node": "24.13.6", + "typescript": "7.0.2" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@types/node": { + "version": "24.13.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.6.tgz", + "integrity": "sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/playwright-plugin/package.json b/playwright-plugin/package.json new file mode 100644 index 0000000..05d117e --- /dev/null +++ b/playwright-plugin/package.json @@ -0,0 +1,18 @@ +{ + "name": "openc3-plugin-playwright", + "version": "1.0.0", + "private": true, + "description": "Installs a COSMOS plugin into a running COSMOS and verifies it came up", + "scripts": { + "test": "playwright test --project=chromium", + "typecheck": "tsc", + "list": "playwright test --list" + }, + "dependencies": { + "@playwright/test": "1.62.1" + }, + "devDependencies": { + "@types/node": "24.13.6", + "typescript": "7.0.2" + } +} diff --git a/playwright-plugin/playwright.config.ts b/playwright-plugin/playwright.config.ts new file mode 100644 index 0000000..733c93f --- /dev/null +++ b/playwright-plugin/playwright.config.ts @@ -0,0 +1,62 @@ +import { defineConfig, devices } from '@playwright/test' +import path from 'path' + +export const STORAGE_STATE = path.join(__dirname, 'storageState.json') + +export default defineConfig({ + testDir: './tests', + // Installing a plugin builds and deploys microservices, which on a cold CI + // runner is minutes rather than seconds + timeout: 10 * 60 * 1000, + expect: { + timeout: 30 * 1000, + }, + // Covers the Playwright step only: the setup budget (20 min) plus the install + // test and its one retry. The workflow's 75 minute job timeout adds room on + // top for checkout, the gem build and starting COSMOS, which together can + // take 20 minutes on their own. + globalTimeout: 40 * 60 * 1000, + forbidOnly: !!process.env.CI, + // COSMOS and the browser share one runner, so a starved event loop can freeze + // a page long enough to time out an action. One retry rides that out. + retries: process.env.CI ? 1 : 0, + // The install spec mutates global COSMOS state, so never run specs in parallel. + // Project dependencies ensure tool checks only run after a successful install. + workers: 1, + fullyParallel: false, + reporter: process.env.CI ? [['github'], ['list']] : [['list']], + use: { + actionTimeout: 60 * 1000, + baseURL: process.env.COSMOS_URL || 'http://localhost:2900', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + viewport: { width: 1600, height: 1200 }, + }, + projects: [ + { + name: 'setup', + testMatch: /auth\.setup\.ts/, + }, + { + name: 'install', + testMatch: /01-install-plugin\.spec\.ts/, + dependencies: ['setup'], + use: { + ...devices['Desktop Chrome'], + storageState: STORAGE_STATE, + viewport: { width: 1600, height: 1200 }, + }, + }, + { + name: 'chromium', + testMatch: /02-tool-console\.spec\.ts/, + dependencies: ['install'], + use: { + ...devices['Desktop Chrome'], + storageState: STORAGE_STATE, + viewport: { width: 1600, height: 1200 }, + }, + }, + ], +}) diff --git a/playwright-plugin/tests/01-install-plugin.spec.ts b/playwright-plugin/tests/01-install-plugin.spec.ts new file mode 100644 index 0000000..5ae8371 --- /dev/null +++ b/playwright-plugin/tests/01-install-plugin.spec.ts @@ -0,0 +1,171 @@ +/* +# Copyright 2026 OpenC3, Inc. +# All Rights Reserved. +# +# This file is licensed under the MIT license. +# See LICENSE.md file in the project root for details. +*/ + +// Installs the plugin gem built by the workflow through the COSMOS Admin tool, +// the same way an operator would, and verifies the install ran to completion. +// +// PLUGIN_GEM path to the .gem file to install (required) +// EXPECTED_TARGETS space separated target names the plugin should define (optional) + +import { test, expect, type Page } from '@playwright/test' +import * as fs from 'fs' +import * as path from 'path' + +const gemPath = process.env.PLUGIN_GEM || '' +const gem = path.basename(gemPath) +// openc3-cosmos-kayhan-1.0.0.gem -> openc3-cosmos-kayhan +const pluginName = gem.replace(/-\d[^-]*\.gem$/, '') +const expectedTargets = (process.env.EXPECTED_TARGETS || '') + .split(/\s+/) + .filter(Boolean) + +// The gem filename goes into a RegExp below, and plugin names are full of dots +// and dashes +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +const escapedGem = escapeRegExp(gem) + +// Installing builds and deploys the plugin's microservices, which on a cold +// runner pulling images is minutes rather than seconds +const INSTALL_TIMEOUT = 10 * 60 * 1000 + +// Every final state a COSMOS process can report. A first install reports the +// bare gem name, a re-install appends __. +const FINISHED = new RegExp( + `^Processing plugin_install: ${escapedGem}(__\\S+)? - (Complete|Warning|Error|Crashed|Expired)$`, +) + +async function readFinishedInstalls(page: Page) { + const rows = await page + .locator('[data-test=process-list] .v-list-item-title') + .allInnerTexts() + return rows.map((row) => row.trim()).filter((row) => FINISHED.test(row)) +} + +test.beforeAll(() => { + expect(gemPath, 'PLUGIN_GEM must be set').toBeTruthy() + expect(fs.existsSync(gemPath), `${gemPath} does not exist`).toBe(true) +}) + +test('installs the plugin', async ({ page }) => { + // Waiting for the install to finish alone is allowed to burn INSTALL_TIMEOUT, so the + // test needs headroom on top of it for the navigation, upload and submit + test.setTimeout(INSTALL_TIMEOUT + 3 * 60 * 1000) + + await page.goto('/tools/admin/plugins') + await expect(page.locator('.v-app-bar')).toContainText('Administrator') + + // waitForEvent must be set up before the click that opens the chooser + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + page.getByRole('button', { name: 'Install From File' }).click(), + ]) + await fileChooser.setFiles(gemPath) + + // Every plugin gets the variables dialog, even with no VARIABLEs to set. + // Submitting takes the defaults from plugin.txt. + await expect(page.locator('.v-dialog:has-text("Variables")')).toBeVisible() + + // The process list keeps the last 10 minutes of activity in no particular + // order, so on a Playwright retry it still holds the first attempt's rows. + // Snapshot the finished ones now so only this attempt's result is judged. + const finishedRows = await readFinishedInstalls(page) + + await page.locator('data-test=edit-submit').click() + + await expect(page.locator('[data-test=plugin-alert]')).toContainText( + 'Started installing', + ) + + // Wait for the install process to reach any final state, so an Error or + // Crashed install fails immediately instead of burning INSTALL_TIMEOUT waiting + // for a Complete that will never come. Deliberately a positive check: the + // process list is only rendered once there are processes, so asserting the + // absence of a Running row can pass before the install has even been queued. + let state: string | undefined + await expect + .poll( + async () => { + const fresh = [...(await readFinishedInstalls(page))] + for (const row of finishedRows) { + const index = fresh.indexOf(row) + if (index !== -1) fresh.splice(index, 1) + } + state = fresh[0]?.match(FINISHED)?.[2] + return state + }, + { + message: `plugin_install of ${gem} never finished`, + timeout: INSTALL_TIMEOUT, + intervals: [2000], + }, + ) + .toBeTruthy() + + // Warning means the plugin installed but something along the way deserves a + // look, so it passes. Error, Crashed and Expired are failures. + expect( + state, + `plugin_install finished with ${state}:\n${await page + .locator('[data-test=process-list]') + .innerText()}`, + ).toMatch(/^(Complete|Warning)$/) + + // A failed install still leaves a process row, so confirm the plugin is + // actually listed rather than trusting the process output alone. + // Deliberately "at least one" and not exactly one: COSMOS names every fresh + // install __, so a Playwright retry after an install that got + // far enough leaves two rows, and an exact count could then never pass. + await expect( + page + .locator('[data-test=plugin-list-item]') + .filter({ hasText: pluginName }) + .first(), + ).toBeVisible() + + for (const target of expectedTargets) { + // Same reason as above: match "some row for this plugin lists the target" + // rather than pinning it to a single row + await expect( + page + .locator('[data-test=plugin-list-item]') + .filter({ hasText: pluginName }) + .filter({ hasText: target }) + .first(), + `${target} is not listed under ${pluginName}`, + ).toBeVisible() + } +}) + +test('loaded the plugin', async ({ page }) => { + await page.goto('/tools/admin/plugins') + await expect(page.locator('.v-app-bar')).toContainText('Administrator') + + // The show-output button only renders once the process is no longer Running, + // which the previous test already waited for + await page + .locator('[data-test=process-list]') + .locator('.v-list-item') + .filter({ hasText: pluginName }) + .locator('[data-test=show-output]') + .first() + .click() + + await expect(page.getByRole('dialog')).toContainText('Process Output') + // openc3cli logs "Loading new plugin: ", and the path is not always + // just the filename, so don't anchor the gem name to the colon. On a + // Playwright retry (or against a stack that already has the plugin) local + // mode routes the same gem down the upgrade path, which logs + // "Updating existing plugin: with " instead. + await expect(page.getByRole('dialog')).toContainText( + new RegExp(`(Loading new plugin|Updating existing plugin): .*${escapedGem}`), + ) + await page.getByRole('button', { name: 'Ok' }).click() +}) diff --git a/playwright-plugin/tests/02-tool-console.spec.ts b/playwright-plugin/tests/02-tool-console.spec.ts new file mode 100644 index 0000000..8fa3f9a --- /dev/null +++ b/playwright-plugin/tests/02-tool-console.spec.ts @@ -0,0 +1,166 @@ +/* +# Copyright 2026 OpenC3, Inc. +# All Rights Reserved. +# +# This file is licensed under the MIT license. +# See LICENSE.md file in the project root for details. +*/ + +// For plugins that ship a COSMOS tool, opens each tool page the plugin added and +// fails if the browser logged any error. A tool that installs cleanly can still +// be broken on load - a bad import, a missing asset, a Vue render error - and +// none of that shows up in the install process output. +// +// Plugins with no tools skip this automatically: the tool list comes from the +// COSMOS API after install, so nothing has to be declared twice. +// +// PLUGIN_GEM path to the installed .gem (required, set by the workflow) +// IS_TOOL 'true' when the plugin is expected to register a tool +// CONSOLE_IGNORE newline separated regexes for console errors to allow + +import { test, expect, Page } from '@playwright/test' +import * as path from 'path' + +const gem = path.basename(process.env.PLUGIN_GEM || '') +const pluginName = gem.replace(/-\d[^-]*\.gem$/, '') +const isTool = process.env.IS_TOOL === 'true' + +// Without this an unset PLUGIN_GEM leaves pluginName as '', and the tool filter +// below ("does tool.plugin contain pluginName") then matches every tool COSMOS +// has installed, silently checking the whole product instead of this plugin +test.beforeAll(() => { + expect(pluginName, 'PLUGIN_GEM must be set').toBeTruthy() +}) + +// Errors that say nothing about the plugin. Deliberately short: every entry +// here is a class of real breakage this test can no longer see. +const DEFAULT_IGNORES = [ + // Fired by the browser when a ResizeObserver callback is still running at the + // next paint. Chromium reports it as an error, it is not one, and any Vuetify + // layout can trip it. + /ResizeObserver loop/, + // The tool pages do not ship their own favicon + /favicon\.ico/, +] + +const ignores = [ + ...DEFAULT_IGNORES, + ...(process.env.CONSOLE_IGNORE || '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((pattern) => new RegExp(pattern)), +] + +function ignored(message: string) { + return ignores.some((pattern) => pattern.test(message)) +} + +interface Tool { + name: string + folder_name: string + url: string | null + window: string + plugin: string | null + shown: boolean +} + +// COSMOS authenticates with a localStorage token rather than a cookie, so the +// request has to be made from inside the page +async function fetchTools(page: Page): Promise { + const result = await page.evaluate(async () => { + const response = await fetch('/openc3-api/tools/all?scope=DEFAULT', { + headers: { Authorization: localStorage.openc3Token }, + }) + if (!response.ok) return { error: `${response.status}` } + return { data: await response.json() } + }) + expect(result.error, 'could not read /openc3-api/tools/all').toBeUndefined() + // The API returns a hash of folder_name => tool + const data = result.data as Record | Tool[] + return Array.isArray(data) ? data : Object.values(data) +} + +// Collect everything the browser complained about while the page was open +function watchForErrors(page: Page, problems: string[]) { + page.on('console', (message) => { + if (message.type() !== 'error') return + const text = message.text() + if (!ignored(text)) problems.push(`console.error: ${text}`) + }) + page.on('pageerror', (error) => { + const text = error.message + if (!ignored(text)) problems.push(`uncaught: ${text}`) + }) +} + +test('tool pages load without console errors', async ({ page, context }) => { + // Any COSMOS page, just to get an authenticated origin to query from + await page.goto('/tools/admin/plugins') + await expect(page.locator('.v-app-bar')).toContainText('Administrator') + + const all = await fetchTools(page) + const mine = all.filter((tool) => (tool.plugin || '').includes(pluginName)) + + // WINDOW is INLINE, IFRAME, SAME or NEW (see ToolModel). Only INLINE tools are + // mounted by COSMOS itself as a single-spa app, so only their console says + // anything about this plugin. IFRAME embeds, and SAME (a plain link in the + // current tab) and NEW (a new tab) navigate to, a page COSMOS doesn't render. + const checkable = mine.filter( + (tool) => tool.window === 'INLINE' && tool.url && tool.url.startsWith('/'), + ) + + if (mine.length === 0) { + // A plugin that declared a tool but registered none is broken, not skippable + expect( + isTool, + `is_tool is set but ${pluginName} registered no tools`, + ).toBe(false) + test.skip(true, `${pluginName} ships no COSMOS tools`) + return + } + + test.skip( + checkable.length === 0, + `${pluginName} ships only IFRAME/SAME/NEW tools, which render outside COSMOS`, + ) + + const failures: string[] = [] + for (const tool of checkable) { + await test.step(`${tool.name} (${tool.url})`, async () => { + const problems: string[] = [] + const toolPage = await context.newPage() + watchForErrors(toolPage, problems) + + // Not networkidle: COSMOS tools poll the API and hold a cable connection + // open, so a tool page is never reliably idle and the wait can burn the + // whole test timeout. The settle and the 404 check below are what decide + // whether the tool actually came up. + await toolPage.goto(tool.url!, { waitUntil: 'domcontentloaded' }) + + // Let deferred work (chunk loads, first data fetch) report itself + await toolPage.waitForTimeout(5000) + + // tool-base renders a catch all "404 Not Found" card for a tool that + // never registered with single-spa, and that is quiet in the console, so + // confirm it is absent before believing a clean console means anything. + // Deliberately not "the app bar says tool.name": a tool's title is + // whatever its own code hands and does not have to equal the + // name in plugin.txt - COSMOS's own tool is TOOL admin Admin and titles + // itself Administrator. + await expect( + toolPage.getByText('404 Not Found'), + `${tool.url} rendered tool-base's 404 instead of the tool`, + ).toHaveCount(0) + + await toolPage.close() + + if (problems.length) { + failures.push(`${tool.name} (${tool.url}):\n ${problems.join('\n ')}`) + } + }) + } + + // Report every tool's problems at once rather than only the first + expect(failures.join('\n'), 'browser errors on tool pages').toBe('') +}) diff --git a/playwright-plugin/tests/auth.setup.ts b/playwright-plugin/tests/auth.setup.ts new file mode 100644 index 0000000..aa90809 --- /dev/null +++ b/playwright-plugin/tests/auth.setup.ts @@ -0,0 +1,93 @@ +/* +# Copyright 2026 OpenC3, Inc. +# All Rights Reserved. +# +# This file is licensed under the MIT license. +# See LICENSE.md file in the project root for details. +*/ + +// Signs in to a freshly started COSMOS Core and saves the session for the specs. +// +// Login.vue asks /openc3-api/auth/token-exists on load. A container that has +// never been used has no password, so it renders the "set a password" form +// (New Password + Confirm Password + Set), which is the CI case. A container +// that already has one renders a single Password field and a Login button. The +// password field carries data-test=new-password either way, so key off whether +// the confirm field is present rather than off the labels, which change text +// between the two states. + +import { test as setup, expect, Page } from '@playwright/test' +import { STORAGE_STATE } from '../playwright.config' + +const PASSWORD = process.env.COSMOS_PASSWORD || 'password' + +// init.sh installs the plugins one at a time, so a tool appearing in the import +// map only means init.sh got that far. A tool that isn't in the map is never +// registered with single-spa and tool-base renders its 404 instead, which a +// spec only sees as a missing app bar. +// +// The marker has to be the LAST inline tool init.sh loads, not the first one we +// happen to need: tool-admin is loaded well before the demo plugin and before +// cmdtlmserver, so waiting on admin would let the specs start while INST/INST2 +// don't exist yet and /tools/cmdtlmserver still 404s. bucketexplorer is the +// last tool init.sh loads that gets an import map entry (docs is iframe based), +// which is the same marker the COSMOS repo's own suite waits on. +const READY_MARKER = '@openc3/tool-bucketexplorer' + +async function waitForTools(page: Page) { + await expect + .poll( + async () => { + try { + const response = await page.request.get('/openc3-api/map.json') + if (!response.ok()) return false + const imports = (await response.json()).imports || {} + return READY_MARKER in imports + } catch { + return false + } + }, + { + message: `waiting for ${READY_MARKER} in the import map`, + timeout: 12 * 60 * 1000, + intervals: [5000], + }, + ) + .toBe(true) +} + +setup('sign in', async ({ page }) => { + setup.setTimeout(20 * 60 * 1000) + + await waitForTools(page) + + await page.goto('/tools/cmdtlmserver') + + // The password form is rendered by tool-base once it decides we're not + // authenticated, which can lag the initial load + await expect(page.locator('[data-test=new-password]')).toBeVisible({ + timeout: 60 * 1000, + }) + await page.locator('[data-test=new-password]').fill(PASSWORD) + + if (await page.locator('[data-test=confirm-password]').isVisible()) { + // First run, COSMOS wants the password created + await page.locator('[data-test=confirm-password]').fill(PASSWORD) + await page.locator('[data-test=set-password]').click() + } else { + // A password already exists, e.g. re-running against a stack still up + await page.locator('button:has-text("Login")').click() + } + + // Confirm the session actually works rather than trusting the form submit + await expect(page.locator('.v-app-bar')).toContainText('CmdTlmServer', { + timeout: 2 * 60 * 1000, + }) + + if (await page.getByText('Clock out of sync').isVisible()) { + await page.locator("text=Don't show this again").click() + await page.locator('button:has-text("Dismiss")').click() + } + + await page.context().storageState({ path: STORAGE_STATE }) +}) diff --git a/playwright-plugin/tsconfig.json b/playwright-plugin/tsconfig.json new file mode 100644 index 0000000..207fc60 --- /dev/null +++ b/playwright-plugin/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["playwright.config.ts", "tests/**/*.ts"] +} diff --git a/workflow-templates/plugin-playwright.properties.json b/workflow-templates/plugin-playwright.properties.json new file mode 100644 index 0000000..414eb23 --- /dev/null +++ b/workflow-templates/plugin-playwright.properties.json @@ -0,0 +1,11 @@ +{ + "name": "Plugin Playwright", + "description": "Runs the latest COSMOS Core release in Docker, builds the plugin, and installs it through the Admin tool with Playwright.", + "iconName": "octicon container", + "categories": [ + "Testing" + ], + "filePatterns": [ + ".*\\.gemspec$" + ] +} diff --git a/workflow-templates/plugin-playwright.yml b/workflow-templates/plugin-playwright.yml new file mode 100644 index 0000000..a9c6ba7 --- /dev/null +++ b/workflow-templates/plugin-playwright.yml @@ -0,0 +1,68 @@ +# Builds the plugin and installs it into a real COSMOS with Playwright. +# Copy this file to .github/workflows/ in the plugin repo and: +# 1. Update the workflow name if necessary (if you're in cosmos-enterprise-plugins and have multiple plugins) +# 2. Update the workflow filename if necessary +# 3. Update plugin_directory argument. This should be "." for single repos, and e.g. "openc3-cosmos-orbitview" for cosmos-enterprise-plugins. +# 4. Set expected_targets to the target names your plugin defines +# 5. Set install_demo: true if your plugin's defaults reference the demo targets (INST, INST2) +# 6. Replace $default-branch under `push:` with your default branch name. +# GitHub substitutes it only when you start the workflow from the Actions +# tab; a hand copied file keeps the literal, which matches no branch and +# silently never runs on push. +# +# Runs the most recent COSMOS Core release in Docker, so it catches what unit +# tests can't: a plugin.txt that doesn't parse, a target that doesn't build, a +# microservice that won't start. Plugins that register a COSMOS tool also get +# each tool page opened and checked for browser console errors. Runs on every +# commit and once a week, the weekly run being what catches a new COSMOS +# release breaking the plugin. +# +# GitHub disables scheduled workflows in a repo with no activity for 60 days. + +name: Plugin Playwright + +on: + push: + branches: [$default-branch] + pull_request: + branches: ["**"] + # Weekly against whatever COSMOS release is current, so a new COSMOS that + # breaks the plugin surfaces even when nothing here has changed. Off the hour + # because scheduled runs at :00 get queued behind everyone else's. + schedule: + - cron: '27 7 * * 1' + workflow_dispatch: + inputs: + cosmos_version: + description: 'COSMOS version to run, or latest' + required: false + type: string + default: 'latest' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + # Superseding a pull request run is fine, but a scheduled run and a push to + # main share a ref, so cancelling on those would have them kill each other + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + playwright: + uses: OpenC3/.github/.github/workflows/plugin-playwright-reusable.yml@main + with: + plugin_directory: . # Update this + expected_targets: MY_TARGET # Update this + cosmos_version: ${{ inputs.cosmos_version || 'latest' }} + # install_demo: true # Uncomment if the plugin's defaults reference INST/INST2 + # A plugin with a package.json is built with pnpm automatically, so there + # is nothing to set for a tool or widget frontend. Uncomment is_tool only + # to assert the plugin really does register a COSMOS tool, which turns + # registering none from a skip into a failure. + # is_tool: true + # Tool pages are opened and checked for browser console errors automatically + # when the plugin registers a tool. Add regexes here for errors to allow: + # console_ignore: | + # some third party gripe + # another known message diff --git a/workflow-templates/plugin-unit-tests.properties.json b/workflow-templates/plugin-unit-tests.properties.json new file mode 100644 index 0000000..148b5ce --- /dev/null +++ b/workflow-templates/plugin-unit-tests.properties.json @@ -0,0 +1,11 @@ +{ + "name": "Plugin Unit Tests", + "description": "Runs Python tests in tests/ or test/ and Ruby specs in spec/ or specs/ on every push and pull request.", + "iconName": "octicon beaker", + "categories": [ + "Testing" + ], + "filePatterns": [ + ".*\\.gemspec$" + ] +} diff --git a/workflow-templates/plugin-unit-tests.yml b/workflow-templates/plugin-unit-tests.yml new file mode 100644 index 0000000..a395e22 --- /dev/null +++ b/workflow-templates/plugin-unit-tests.yml @@ -0,0 +1,36 @@ +# Runs a plugin's unit tests on every push and pull request. +# Copy this file to .github/workflows/ in the plugin repo and: +# 1. Update the workflow name if necessary (if you're in cosmos-enterprise-plugins and have multiple plugins) +# 2. Update the workflow filename if necessary +# 3. Update plugin_directory argument. This should be "." for single repos, and e.g. "openc3-cosmos-orbitview" for cosmos-enterprise-plugins. +# 4. Replace $default-branch under `push:` with your default branch name. +# GitHub substitutes it only when you start the workflow from the Actions +# tab; a hand copied file keeps the literal, which matches no branch and +# silently never runs on push. +# +# Python tests are discovered in tests/ or test/, Ruby specs in spec/ or specs/. +# Whichever the plugin doesn't have is skipped, so there is nothing else to set. + +name: Plugin Unit Tests + +on: + push: + branches: [$default-branch] + pull_request: + branches: ["**"] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + unit-tests: + uses: OpenC3/.github/.github/workflows/plugin-unit-tests-reusable.yml@main + with: + plugin_directory: . # Update this + # python_version: '3.12' # Uncomment and update if needed + # ruby_version: '3.4' # Uncomment and update if needed + # require_tests: true # Uncomment to fail the build when the plugin has no tests diff --git a/workflow-templates/release-cosmos-plugin.yml b/workflow-templates/release-cosmos-plugin.yml index c672bff..7f3d70b 100644 --- a/workflow-templates/release-cosmos-plugin.yml +++ b/workflow-templates/release-cosmos-plugin.yml @@ -4,6 +4,8 @@ # 2. Update the workflow filename if necessary # 3. Update plugin_directory argument. This should be "." for single repos, and e.g. "openc3-cosmos-orbitview" for cosmos-enterprise-plugins. # 4. Add the OpenC3 admin accont API token to the repo secrets as OPENC3_API_TOKEN +# 5. To publish to RubyGems, add your RubyGems API key to the repo secrets as +# RUBYGEMS_API_KEY and check 'Publish the gem to RubyGems' when you run this name: Release COSMOS Plugin @@ -19,6 +21,11 @@ on: required: false type: boolean default: true + deploy_to_rubygems: + description: 'Publish the gem to RubyGems' + required: false + type: boolean + default: false jobs: release: @@ -26,6 +33,8 @@ jobs: with: version: ${{ inputs.version }} deploy_to_store: ${{ inputs.deploy_to_store }} + deploy_to_rubygems: ${{ inputs.deploy_to_rubygems }} plugin_directory: openc3-cosmos-foo-plugin # Update this secrets: OPENC3_API_TOKEN: ${{ secrets.OPENC3_API_TOKEN }} + RUBYGEMS_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }} diff --git a/workflow-templates/release-cosmos-tool.yml b/workflow-templates/release-cosmos-tool.yml index 6e04233..467e246 100644 --- a/workflow-templates/release-cosmos-tool.yml +++ b/workflow-templates/release-cosmos-tool.yml @@ -4,7 +4,9 @@ # 2. Update the workflow filename if necessary # 3. Update plugin_directory argument. This should be "." for single repos, and e.g. "openc3-cosmos-orbitview" for cosmos-enterprise-plugins. # 4. Add the OpenC3 admin accont API token to the repo secrets as OPENC3_API_TOKEN -# 5. Make sure pnpm-workspace.yaml has the `packages` field defined +# 5. To publish to RubyGems, add your RubyGems API key to the repo secrets as +# RUBYGEMS_API_KEY and check 'Publish the gem to RubyGems' when you run this +# 6. Make sure pnpm-workspace.yaml has the `packages` field defined name: Release COSMOS Tool @@ -20,6 +22,11 @@ on: required: false type: boolean default: true + deploy_to_rubygems: + description: 'Publish the gem to RubyGems' + required: false + type: boolean + default: false jobs: release: @@ -27,7 +34,12 @@ jobs: with: version: ${{ inputs.version }} deploy_to_store: ${{ inputs.deploy_to_store }} + deploy_to_rubygems: ${{ inputs.deploy_to_rubygems }} plugin_directory: openc3-cosmos-tool-foo # Update this + # The frontend is built whenever the plugin has a package.json. is_tool + # only asserts that it has one, so a missing package.json fails the + # release instead of publishing a gem with no tool in it. is_tool: true secrets: OPENC3_API_TOKEN: ${{ secrets.OPENC3_API_TOKEN }} + RUBYGEMS_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}