diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..862e5799e --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,35 @@ +# Code owners are requested for review automatically when a PR touches their +# paths. Combined with a branch protection rule requiring code owner review, +# this is what stops an unreviewed change to the release or publishing +# workflows from landing. +# +# ───────────────────────────────────────────────────────────────────────────── +# ACTION REQUIRED: the rules below are commented out because they need real +# GitHub handles or team slugs. Replace @meshtastic/OWNERS with the actual +# owner(s) and uncomment. Rules naming a nonexistent user or team are silently +# ignored by GitHub, so it is better to leave them off than to guess. +# +# Later rules win over earlier ones, so keep the catch-all first. +# ───────────────────────────────────────────────────────────────────────────── + +# Default owners for everything in the repo +# * @meshtastic/OWNERS + +# CI, release, and publishing pipelines — these hold the credentials that push +# to GHCR, npm, and JSR, so they warrant the tightest review. +# /.github/workflows/ @meshtastic/OWNERS +# /.github/dependabot.yml @meshtastic/OWNERS +# /.github/CODEOWNERS @meshtastic/OWNERS + +# Container build and deployment +# /apps/web/infra/ @meshtastic/OWNERS + +# Transport layer — device connectivity (BLE / Serial / HTTP) +# /packages/transport-*/ @meshtastic/OWNERS + +# Protocol definitions +# /packages/protobufs/ @meshtastic/OWNERS + +# Translation sources. Crowdin owns the non-English locales; changes to the +# English source strings should still get a maintainer's eyes. +# /apps/web/public/i18n/locales/en/ @meshtastic/OWNERS diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..29c542121 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,17 @@ +blank_issues_enabled: false +contact_links: + - name: 💬 Meshtastic Discord + url: https://discord.gg/meshtastic + about: Real-time help with setup, connection problems, and general usage. Start here if you're not sure it's a bug. + - name: 🗣️ Meshtastic Forum + url: https://meshtastic.discourse.group + about: Longer-form discussion, ideas, and community support. + - name: 📚 Documentation + url: https://meshtastic.org/docs/software/web-client + about: Web client docs, including connection setup and troubleshooting. + - name: 🔌 Firmware or device issues + url: https://github.com/meshtastic/firmware/issues + about: Report problems with the device firmware itself, not the web client. + - name: 🔐 Report a security vulnerability + url: https://github.com/meshtastic/web/security/advisories/new + about: Please report security issues privately, not as a public issue. diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 000000000..74df4b8cc --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,53 @@ +# Security Policy + +## Supported Versions + +Security fixes land on `main` and ship in the next release. Only the most +recent release and the `nightly` container image receive fixes; older tags are +not patched. + +| Version | Supported | +| -------------------------------- | ------------------ | +| Latest release / `stable` branch | :white_check_mark: | +| `nightly` | :white_check_mark: | +| Older tagged releases | :x: | + +## Reporting a Vulnerability + +**Please do not open a public issue for security vulnerabilities.** + +Report privately through GitHub Security Advisories: + + + +Please include: + +- A description of the issue and its impact +- Steps to reproduce, or a proof of concept +- The affected version, browser, and connection type (HTTP / Bluetooth / Serial) +- Any suggested mitigation + +We aim to acknowledge reports within 7 days and to provide a remediation plan +within 30 days. We will credit you in the advisory unless you prefer otherwise. + +## Scope + +This repository is the Meshtastic **web client**. In scope: + +- Cross-site scripting, injection, or other client-side vulnerabilities +- Mishandling of channel PSKs, private keys, or other device secrets +- Flaws in the Bluetooth, Serial, or HTTP transport implementations +- Supply-chain issues in this repository's build and release workflows + +Out of scope — report these to the relevant repository instead: + +- Device firmware: +- Protocol design: +- Findings that require physical access to an already-unlocked device +- Vulnerabilities in the user's browser or operating system + +## Handling Device Secrets + +The web client handles channel pre-shared keys and node private keys. When +reporting an issue, please redact any real keys from logs, screenshots, and +reproduction steps. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7b96cd0f6..7607c736c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,6 +4,16 @@ updates: directory: "/" schedule: interval: "weekly" + # Action bumps are low-risk and were the single largest source of PR churn; + # batch them into one PR per week. + groups: + github-actions: + patterns: + - "*" + labels: + - "dependencies" + - "github-actions" + - package-ecosystem: "npm" directory: "/" schedule: @@ -11,6 +21,8 @@ updates: cooldown: default-days: 3 # 1 day after pnpm's cutoff, prevent a race open-pull-requests-limit: 10 # Increase PR limit + labels: + - "dependencies" groups: tanstack: patterns: @@ -23,3 +35,13 @@ updates: - react - react-dom - "@types/react*" + # Catch-all for tooling, so lint/format/test/build bumps land together + # instead of one PR per package. + dev-dependencies: + dependency-type: "development" + patterns: + - "*" + exclude-patterns: + - "@tanstack/*" + - "@radix-ui/*" + - "@types/react*" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d3fe3f05f..c3aa39933 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -43,8 +43,15 @@ If your changes affect the UI, include screenshots or screencasts showing the be Check all that apply. If an item doesn't apply to your PR, you can leave it unchecked or remove it. --> -- [ ] Code follows project style guidelines +- [ ] `pnpm run check` passes (lint + format) +- [ ] `pnpm --filter ./apps/web run typecheck` passes +- [ ] `pnpm exec vitest run` passes - [ ] Documentation has been updated or added - [ ] Tests have been added or updated -- [ ] All i18n translation labels have been added (read - CONTRIBUTING_I18N_DEVELOPER_GUIDE.md for more details) +- [ ] All i18n translation labels have been added (see the + [i18n developer guide](../apps/web/CONTRIBUTING_I18N_DEVELOPER_GUIDE.md)) + + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54d5c0518..95c8beb44 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,13 +5,18 @@ on: branches: - main +# This workflow only builds; it publishes nothing. permissions: - contents: write - packages: write + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true jobs: build-and-package: runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Checkout code uses: actions/checkout@v7 diff --git a/.github/workflows/crowdin-download.yml b/.github/workflows/crowdin-download.yml index 95d6962fb..004bc441d 100644 --- a/.github/workflows/crowdin-download.yml +++ b/.github/workflows/crowdin-download.yml @@ -5,9 +5,19 @@ on: - cron: "0 0 * * 0" workflow_dispatch: # Allow manual triggering +# Needed to push the translation branch and open the PR. +permissions: + contents: write + pull-requests: write + +concurrency: + group: crowdin-download + cancel-in-progress: false + jobs: synchronize-with-crowdin: runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Checkout diff --git a/.github/workflows/crowdin-upload-sources.yml b/.github/workflows/crowdin-upload-sources.yml index ed6f9f9f8..b7b018cd6 100644 --- a/.github/workflows/crowdin-upload-sources.yml +++ b/.github/workflows/crowdin-upload-sources.yml @@ -2,16 +2,26 @@ name: Crowdin Upload Sources Action on: push: - # Monitor all .json files within the /i18n/locales/en/ directory. + # Monitor all .json files within the English locale directory. # This ensures the workflow triggers if any the English namespace files are modified on the main branch. + # NOTE: path filters are relative to the repo root and must NOT start with "/", + # otherwise the glob never matches and this workflow silently never runs. paths: - - "/apps/web/public/i18n/locales/en/*.json" + - "apps/web/public/i18n/locales/en/*.json" branches: [main] workflow_dispatch: # Allow manual triggering +permissions: + contents: read + +concurrency: + group: crowdin-upload-sources + cancel-in-progress: false + jobs: synchronize-with-crowdin: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Checkout diff --git a/.github/workflows/crowdin-upload-translations.yml b/.github/workflows/crowdin-upload-translations.yml index 86f23c88b..f3e74bc89 100644 --- a/.github/workflows/crowdin-upload-translations.yml +++ b/.github/workflows/crowdin-upload-translations.yml @@ -3,9 +3,17 @@ name: Crowdin Upload Translations Action on: workflow_dispatch: # Allow manual triggering +permissions: + contents: read + +concurrency: + group: crowdin-upload-translations + cancel-in-progress: false + jobs: synchronize-with-crowdin: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Checkout diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 25b94e043..d4e472dbc 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -13,10 +13,21 @@ on: - "playwright.config.ts" - ".github/workflows/e2e.yml" +permissions: + contents: read + +# This is the most expensive workflow in the repo (Docker mesh + Playwright). +# Superseded runs on the same PR are pointless, so cancel them. +concurrency: + group: e2e-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: e2e: runs-on: ubuntu-latest timeout-minutes: 30 + # Drafts don't need a 30-minute device suite on every push. + if: ${{ github.event_name == 'workflow_dispatch' || github.event.pull_request.draft == false }} env: CI: "true" steps: @@ -32,12 +43,33 @@ jobs: - name: Install workspace deps run: pnpm install --frozen-lockfile + # Key the browser cache on the resolved Playwright version so a dependency + # bump invalidates it instead of restoring a mismatched browser build. + - name: Resolve Playwright version + id: pw + run: echo "version=$(pnpm exec playwright --version | awk '{print $2}')" >> "$GITHUB_OUTPUT" + + - name: Cache Playwright browsers + id: pw-cache + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ steps.pw.outputs.version }} + - name: Install Chromium + if: steps.pw-cache.outputs.cache-hit != 'true' run: pnpm exec playwright install --with-deps chromium + # Cached browsers still need their OS-level shared libraries. + - name: Install Chromium system deps + if: steps.pw-cache.outputs.cache-hit == 'true' + run: pnpm exec playwright install-deps chromium + - uses: actions/setup-python@v7 with: python-version: "3.11" + cache: pip + cache-dependency-path: e2e/peer/requirements.txt - name: Install Python mesh peer run: | diff --git a/.github/workflows/inactive-issue.yml b/.github/workflows/inactive-issue.yml index 4e13bc653..77a5d820a 100644 --- a/.github/workflows/inactive-issue.yml +++ b/.github/workflows/inactive-issue.yml @@ -1,11 +1,22 @@ name: Close inactive issues on: schedule: - - cron: "0 */4 * * *" # every 4 hours + - cron: "30 1 * * *" # daily at 01:30 UTC workflow_dispatch: # allow manual runs + +# Issues take 60 days to go stale; running more than once a day just burns +# minutes against the action's per-run operations budget. +permissions: + contents: read + +concurrency: + group: stale + cancel-in-progress: false + jobs: close-issues: runs-on: ubuntu-latest + timeout-minutes: 15 permissions: issues: write pull-requests: write @@ -20,5 +31,7 @@ jobs: close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale." days-before-pr-stale: -1 days-before-pr-close: -1 - only-issue-labels: "Bug" + # Must match the label applied by .github/ISSUE_TEMPLATE/bug.yml. + only-issue-labels: "bug" + exempt-issue-labels: "pinned,security,help wanted" remove-stale-when-updated: true diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index d835e558d..f69d1ef23 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -9,12 +9,48 @@ permissions: contents: read packages: write +concurrency: + group: nightly + cancel-in-progress: false + env: REGISTRY_IMAGE: ghcr.io/${{ github.repository }} jobs: + # A scheduled nightly on an unchanged tree just republishes the same image. + # Skip it unless something landed in the last day (manual runs always build). + check-for-changes: + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + should-build: ${{ steps.check.outputs.should-build }} + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Check for commits in the last 24h + id: check + shell: bash + run: | + set -euo pipefail + if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then + echo "Manual run - building regardless of commit activity." + echo "should-build=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ -n "$(git log --since='24 hours ago' --format=%H)" ]; then + echo "New commits found - building." + echo "should-build=true" >> "$GITHUB_OUTPUT" + else + echo "No commits in the last 24h - skipping nightly build." + echo "should-build=false" >> "$GITHUB_OUTPUT" + fi + nightly-build-and-push: + needs: check-for-changes + if: ${{ needs.check-for-changes.outputs.should-build == 'true' }} runs-on: ubuntu-latest + timeout-minutes: 60 steps: - name: Checkout code @@ -34,7 +70,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Run tests - run: pnpm run test + run: pnpm exec vitest run - name: Build web package working-directory: apps/web @@ -84,35 +120,38 @@ jobs: - name: Set up QEMU uses: docker/setup-qemu-action@v4 - - name: Build Container Image (multi-arch) - id: build-container - uses: redhat-actions/buildah-build@v3 - with: - containerfiles: | - ./apps/web/infra/Containerfile - image: ${{ env.REGISTRY_IMAGE }} - tags: | - ${{ steps.meta.outputs.moving_tag }} - ${{ steps.meta.outputs.immutable_tag }} - oci: true - platforms: linux/amd64,linux/arm64,linux/arm/v7 - labels: | - org.opencontainers.image.source=${{ github.repository }} - org.opencontainers.image.revision=${{ github.sha }} - org.opencontainers.image.created=${{ steps.meta.outputs.created }} + - name: Set up Buildx + uses: docker/setup-buildx-action@v4 - - name: Push To GHCR - id: push-to-registry - uses: redhat-actions/push-to-registry@v3 + - name: Login to GHCR + uses: docker/login-action@v4 with: - image: ${{ steps.build-container.outputs.image }} - # Push the same tags used at build time: - tags: ${{ steps.meta.outputs.all_tags }} registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + # Same builder as release-web.yml, so the nightly actually exercises the + # release image build path instead of a parallel buildah/podman stack. + - name: Build and push image (multi-arch) + uses: docker/build-push-action@v7 + with: + context: . + file: ./apps/web/infra/Containerfile + platforms: linux/amd64,linux/arm64,linux/arm/v7 + push: true + provenance: false + tags: | + ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.moving_tag }} + ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.immutable_tag }} + # image.source must be a URL for GHCR to link the package to this repo. + labels: | + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.created=${{ steps.meta.outputs.created }} + cache-from: type=gha + cache-to: type=gha,mode=max + - name: Print image URLs run: | - echo "Moving tag: ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.moving_tag }}" + echo "Moving tag: ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.moving_tag }}" echo "Immutable tag: ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.immutable_tag }}" diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 5f2ac3152..abad6cdee 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -17,6 +17,7 @@ env: jobs: build-and-package: runs-on: ubuntu-latest + timeout-minutes: 20 if: ${{ github.event.pull_request.draft == false }} steps: @@ -33,29 +34,36 @@ jobs: cache: pnpm cache-dependency-path: pnpm-lock.yaml - - name: Debug dependency state - run: | - set -euo pipefail - echo "GITHUB_SHA=${GITHUB_SHA}" - git log --oneline -1 - pnpm --version - node --version - grep -n "meshtastic__protobufs" pnpm-lock.yaml || true - grep -n "2.7.18" pnpm-lock.yaml || true - grep -n "2.7.20" pnpm-lock.yaml || true - - name: Install dependencies run: pnpm install --frozen-lockfile - name: Run linter run: pnpm run lint - - name: Check formatter - run: pnpm run check + # `pnpm run check` is `lint && format`, so calling it here would re-run the + # linter. Call the formatter directly instead. + # + # TODO(ci): make this blocking. Non-blocking today because oxfmt currently + # reports 611 of 624 files as unformatted (drift from the oxfmt 0.60 bump). + # Run `pnpm run format:fix` as a standalone sweep commit, then delete + # `continue-on-error` below. + - name: Check formatting + run: pnpm run format + continue-on-error: true + + # TODO(ci): make this blocking. Non-blocking today because of ~25 + # pre-existing errors: + # - `better-result` no longer exports `ResultType` (widespread) + # - two copies of the protobufs types resolve at once (the workspace + # `packages/protobufs` build vs `@jsr/meshtastic__protobufs@2.7.20`), + # so structurally identical enums/messages are treated as unrelated + # Delete `continue-on-error` once those are resolved. + - name: Typecheck + run: pnpm --filter "./apps/web" run typecheck continue-on-error: true - name: Run tests - run: pnpm run test + run: pnpm exec vitest run - name: Build web package run: pnpm --filter "./apps/web" run build diff --git a/.github/workflows/release-packages.yml b/.github/workflows/release-packages.yml index d972d865e..6d395a55e 100644 --- a/.github/workflows/release-packages.yml +++ b/.github/workflows/release-packages.yml @@ -12,14 +12,26 @@ on: required: false default: "patch" +permissions: + contents: read + +concurrency: + group: release-packages + cancel-in-progress: false + jobs: release: runs-on: ubuntu-latest + timeout-minutes: 45 permissions: contents: write # we commit the bumped versions back - id-token: write # required for JSR OIDC + pull-requests: write # `gh pr create` for the version bump + id-token: write # required for JSR OIDC and npm provenance env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + # Workflow inputs are untrusted text; keep them out of every script body. + INPUT_PACKAGES: ${{ inputs.packages }} + INPUT_BUMP: ${{ inputs.bump }} steps: - name: Checkout code uses: actions/checkout@v7 @@ -43,22 +55,38 @@ jobs: shell: bash run: | set -euo pipefail - if [ "${{ github.event.inputs.packages }}" = "all" ] || [ -z "${{ github.event.inputs.packages }}" ]; then + if [ "${INPUT_PACKAGES}" = "all" ] || [ -z "${INPUT_PACKAGES}" ]; then mapfile -t TARGETS < <(ls -d packages/*) else - IFS=',' read -ra TARGETS <<< "${{ github.event.inputs.packages }}" + IFS=',' read -ra TARGETS <<< "${INPUT_PACKAGES}" TARGETS=("${TARGETS[@]/#/packages/}") fi + + # Validate every target before anything mutates the tree, so a typo + # fails fast instead of half-publishing. + for dir in "${TARGETS[@]}"; do + if [ ! -f "$dir/package.json" ]; then + echo "::error::No package.json at '$dir' - check the 'packages' input." >&2 + exit 1 + fi + done + printf '%s\n' "${TARGETS[@]}" | paste -sd, - > targets.txt echo "list=$(cat targets.txt)" >> "$GITHUB_OUTPUT" echo "Targets: $(cat targets.txt)" - name: Bump package.json versions (no git tag) shell: bash + env: + TARGET_LIST: ${{ steps.pkgs.outputs.list }} run: | set -euo pipefail - BUMP="${{ github.event.inputs.bump }}" - IFS=',' read -ra TARGETS <<< "${{ steps.pkgs.outputs.list }}" + BUMP="${INPUT_BUMP}" + case "$BUMP" in + patch|minor|major) ;; + *) echo "::error::bump must be patch, minor, or major (got '$BUMP')." >&2; exit 1 ;; + esac + IFS=',' read -ra TARGETS <<< "${TARGET_LIST}" for dir in "${TARGETS[@]}"; do if [ -f "$dir/package.json" ]; then echo "Bumping $dir -> $BUMP" @@ -68,9 +96,11 @@ jobs: - name: Generate jsr.json from package.json (pkg-to-jsr) shell: bash + env: + TARGET_LIST: ${{ steps.pkgs.outputs.list }} run: | set -euo pipefail - IFS=',' read -ra TARGETS <<< "${{ steps.pkgs.outputs.list }}" + IFS=',' read -ra TARGETS <<< "${TARGET_LIST}" for dir in "${TARGETS[@]}"; do if [ -f "$dir/package.json" ]; then echo "Generating jsr.json for $dir" @@ -89,21 +119,31 @@ jobs: git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add packages/*/package.json packages/*/jsr.json 2>/dev/null || true - if ! git diff --cached --quiet; then - git checkout -b version_bump - git commit -m "chore(release): bump package versions (${{ github.event.inputs.bump }})" - git push -u origin version_bump - gh pr create --title "Bump package versions" --body "Bump versions of NPM and JSR packages" --base main --head version_bump - echo "Pushed to branch version_bump" - else + if git diff --cached --quiet; then echo "No changes to commit." + exit 0 fi + # One branch per run: a fixed `version_bump` branch makes the second + # run fail on `checkout -b` (or silently reuse a stale branch). + BRANCH="release/version-bump-${GITHUB_RUN_ID}" + git checkout -b "$BRANCH" + git commit -m "chore(release): bump package versions (${INPUT_BUMP})" + git push -u origin "$BRANCH" + gh pr create \ + --title "chore(release): bump package versions (${INPUT_BUMP})" \ + --body "Automated version bump for NPM and JSR packages from run [\`${GITHUB_RUN_ID}\`](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})." \ + --base "${GITHUB_REF_NAME}" \ + --head "$BRANCH" + echo "Opened version bump PR from $BRANCH" + - name: Build selected packages shell: bash + env: + TARGET_LIST: ${{ steps.pkgs.outputs.list }} run: | set -euo pipefail - IFS=',' read -ra TARGETS <<< "${{ steps.pkgs.outputs.list }}" + IFS=',' read -ra TARGETS <<< "${TARGET_LIST}" for dir in "${TARGETS[@]}"; do echo "Building $dir" pnpm --filter "./$dir" run build @@ -111,9 +151,11 @@ jobs: - name: Publish to JSR (OIDC) shell: bash + env: + TARGET_LIST: ${{ steps.pkgs.outputs.list }} run: | set -euo pipefail - IFS=',' read -ra TARGETS <<< "${{ steps.pkgs.outputs.list }}" + IFS=',' read -ra TARGETS <<< "${TARGET_LIST}" for dir in "${TARGETS[@]}"; do if [ -f "$dir/jsr.json" ]; then echo "Publishing $dir to JSR via OIDC…" @@ -126,20 +168,24 @@ jobs: - name: Configure npm auth run: | - pnpm config set //registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN} + pnpm config set //registry.npmjs.org/:_authToken="${NODE_AUTH_TOKEN}" pnpm config set registry https://registry.npmjs.org/ - name: Publish to npm shell: bash + env: + TARGET_LIST: ${{ steps.pkgs.outputs.list }} run: | set -euo pipefail - IFS=',' read -ra TARGETS <<< "${{ steps.pkgs.outputs.list }}" + IFS=',' read -ra TARGETS <<< "${TARGET_LIST}" for dir in "${TARGETS[@]}"; do if [ -f "$dir/package.json" ]; then echo "Publishing $dir to npm…" ( cd "$dir" [ -d dist ] || pnpm run build - npm publish --access public + # --provenance uses the job's OIDC token to publish a signed + # build attestation alongside the tarball. + npm publish --access public --provenance ) fi done diff --git a/.github/workflows/release-protobufs.yml b/.github/workflows/release-protobufs.yml index d71f95127..0f693a130 100644 --- a/.github/workflows/release-protobufs.yml +++ b/.github/workflows/release-protobufs.yml @@ -13,7 +13,13 @@ on: type: boolean default: false -permissions: write-all +# Least privilege by default; `publish-jsr` widens this for OIDC below. +permissions: + contents: read + +concurrency: + group: release-protobufs-${{ inputs.version }} + cancel-in-progress: false env: PROTOBUF_DIR: ./packages/protobufs @@ -21,6 +27,7 @@ env: jobs: codegen: runs-on: ubuntu-24.04 + timeout-minutes: 20 steps: - name: Checkout code uses: actions/checkout@v7 @@ -34,14 +41,22 @@ jobs: cat $PROTOBUF_DIR/packages/ts/deno.json - name: Determine VERSION + # Workflow inputs are untrusted text; never interpolate into the script body. + env: + INPUT_VERSION: ${{ inputs.version }} run: | set -euo pipefail - if [ -n "${{ inputs.version }}" ]; then - VERSION="${{ inputs.version }}" + if [ -n "${INPUT_VERSION}" ]; then + VERSION="${INPUT_VERSION}" else echo "No 'version' input. Provide inputs.version." >&2 exit 1 fi + # Reject anything that isn't a plain semver-ish tag before it reaches jq/git. + if ! printf '%s' "$VERSION" | grep -qE '^v?[0-9]+\.[0-9]+\.[0-9]+([-+.][0-9A-Za-z.-]+)?$'; then + echo "::error::Invalid version '$VERSION' (expected e.g. v1.2.3)." >&2 + exit 1 + fi STRIPPED="${VERSION#v}" echo "VERSION=$STRIPPED" >> "$GITHUB_ENV" echo "Resolved VERSION=$STRIPPED" @@ -63,8 +78,9 @@ jobs: mv "$f.tmp" "$f" done + # Pinned: a publishing workflow must not track a moving branch. - name: Setup Buf - uses: bufbuild/buf-setup-action@main + uses: bufbuild/buf-setup-action@v1 with: github_token: ${{ github.token }} @@ -114,6 +130,7 @@ jobs: publish-jsr: runs-on: ubuntu-24.04 needs: codegen + timeout-minutes: 15 permissions: contents: read id-token: write @@ -125,10 +142,11 @@ jobs: with: name: ts_code + # Pinned to a release tag and a stable Deno line rather than @main + rc. - name: Set up Deno - uses: denoland/setup-deno@main + uses: denoland/setup-deno@v2 with: - deno-version: rc + deno-version: v2.x - name: Publish to JSR run: | diff --git a/.github/workflows/release-web.yml b/.github/workflows/release-web.yml index c8541822d..f7e691961 100644 --- a/.github/workflows/release-web.yml +++ b/.github/workflows/release-web.yml @@ -23,12 +23,17 @@ permissions: contents: write packages: write +concurrency: + group: release-web-${{ github.event.release.tag_name || inputs.tag_name || github.ref }} + cancel-in-progress: false + env: REGISTRY_IMAGE: ghcr.io/${{ github.repository }} jobs: release-web: runs-on: ubuntu-latest + timeout-minutes: 60 steps: - name: Checkout code uses: actions/checkout@v7 @@ -92,8 +97,9 @@ jobs: uses: docker/metadata-action@v6 with: images: ${{ env.REGISTRY_IMAGE }} + # image.source must be a URL for GHCR to link the package to this repo. labels: | - org.opencontainers.image.source=${{ github.repository }} + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} org.opencontainers.image.revision=${{ github.sha }} org.opencontainers.image.created=${{ steps.gen.outputs.created }} tags: | @@ -141,17 +147,25 @@ jobs: - name: Output image refs if: ${{ github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.tag_name != '') }} + shell: bash + # Release tags/titles are user-supplied; keep them out of the script body. + env: + PUSHED_TAGS: ${{ steps.meta.outputs.tags }} + IS_PRERELEASE: ${{ github.event.release.prerelease }} + PULL_TAG: ${{ github.event.release.tag_name || inputs.tag_name || format('adhoc-{0}', steps.gen.outputs.shortsha) }} + SHORTSHA: ${{ steps.gen.outputs.shortsha }} run: | + set -euo pipefail echo "🖼️ Pushed tags:" - echo "${{ steps.meta.outputs.tags }}" + echo "${PUSHED_TAGS}" echo echo "Pull examples:" - if [[ "${{ github.event_name }}" == "release" && "${{ github.event.release.prerelease }}" == "false" ]]; then - echo " docker pull ${{ env.REGISTRY_IMAGE }}:latest" + if [[ "${GITHUB_EVENT_NAME}" == "release" && "${IS_PRERELEASE}" == "false" ]]; then + echo " docker pull ${REGISTRY_IMAGE}:latest" fi # Always available: - echo " docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.event.release.tag_name || inputs.tag_name || format('adhoc-{0}', steps.gen.outputs.shortsha) }}" - echo " docker pull ${{ env.REGISTRY_IMAGE }}:sha-${{ steps.gen.outputs.shortsha }}" + echo " docker pull ${REGISTRY_IMAGE}:${PULL_TAG}" + echo " docker pull ${REGISTRY_IMAGE}:sha-${SHORTSHA}" - name: Explain no image push if: ${{ !(github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.tag_name != '')) }} diff --git a/.github/workflows/update-stable-from-master.yml b/.github/workflows/update-stable-from-master.yml index c803e34a0..93ad4b7ac 100644 --- a/.github/workflows/update-stable-from-master.yml +++ b/.github/workflows/update-stable-from-master.yml @@ -7,14 +7,17 @@ on: permissions: contents: write +# A static group so overlapping releases serialize instead of racing to +# force-push `stable`. Never cancel: a half-run leaves `stable` inconsistent. concurrency: - group: update-stable-${{ github.run_id }} - cancel-in-progress: true + group: update-stable + cancel-in-progress: false jobs: update-stable-branch: name: Update stable from latest release source runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Checkout repository uses: actions/checkout@v7 @@ -30,11 +33,16 @@ jobs: - name: Determine source SHA (prefer tag) id: meta shell: bash + # Release metadata is attacker-influenceable text; pass it through env + # rather than interpolating it into the script body. + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + RELEASE_TARGET: ${{ github.event.release.target_commitish }} run: | set -euo pipefail - TAG="${{ github.event.release.tag_name }}" - TARGET="${{ github.event.release.target_commitish || '' }}" + TAG="${RELEASE_TAG}" + TARGET="${RELEASE_TARGET:-}" # Always ensure we have latest remote heads/tags git fetch --tags --prune origin @@ -73,13 +81,15 @@ jobs: - name: Create/reset local stable to SHA shell: bash + env: + SOURCE_SHA: ${{ steps.meta.outputs.sha }} run: | set -euo pipefail # Make sure we know if remote stable exists (non-fatal if not) git fetch origin stable:refs/remotes/origin/stable || true # Create or reset local stable in one command - git checkout -B stable "${{ steps.meta.outputs.sha }}" + git checkout -B stable "${SOURCE_SHA}" git status --short --branch - name: Push stable (force-with-lease) diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml new file mode 100644 index 000000000..5095126d2 --- /dev/null +++ b/.github/workflows/workflow-lint.yml @@ -0,0 +1,67 @@ +name: Workflow Lint + +# Static analysis for the workflows themselves: +# - actionlint: YAML/expression/shell errors (catches things like a `run:` +# block whose bash won't parse, or a bad `needs:` reference) +# - zizmor: workflow security audit (over-broad permissions, template +# injection into `run:`, unpinned actions, artifact poisoning) +on: + push: + branches: [main] + paths: + - ".github/workflows/**" + - ".github/actions/**" + pull_request: + paths: + - ".github/workflows/**" + - ".github/actions/**" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: workflow-lint-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + actionlint: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Run actionlint + uses: docker://rhysd/actionlint:latest + with: + args: -color + + zizmor: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + security-events: write # upload SARIF to code scanning + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + # `--persona=regular` skips the pedantic/auditor findings that would flag + # most of the ecosystem. Non-blocking on the first pass: flip + # continue-on-error off once the existing findings are triaged. + - name: Run zizmor + run: uvx zizmor --persona=regular --format=sarif . > zizmor.sarif + continue-on-error: true + + # Skip the upload if zizmor crashed outright and left an empty file, + # rather than failing the job on a malformed SARIF. + - name: Upload SARIF + if: ${{ hashFiles('zizmor.sarif') != '' }} + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: zizmor.sarif + category: zizmor diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..4239ea3c0 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,19 @@ +# Code of Conduct + +This project follows the +[Meshtastic Code of Conduct](https://meshtastic.org/docs/legal/conduct/), which +applies to every Meshtastic space: this repository, the Discord server, the +forum, and any other official channel. + +In short: be respectful, assume good faith, and keep discussion focused on the +technical problem rather than the person. + +## Reporting + +To report behaviour that violates the Code of Conduct: + +- **In this repository** — contact the maintainers privately, or use GitHub's + [report abuse](https://github.com/contact/report-abuse) flow. +- **In Discord or the forum** — contact a moderator in that space directly. + +Reports are handled confidentially. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..0f7ce5b06 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,37 @@ +# Contributing to the Meshtastic Web Client + +Thanks for your interest in contributing! The full contributor guide lives in +the web app package: + +- **[Contributing guide](apps/web/CONTRIBUTING.md)** — development setup, branch + and commit conventions, and the review process +- **[i18n developer guide](apps/web/CONTRIBUTING_I18N_DEVELOPER_GUIDE.md)** — + adding translation keys when you touch user-facing strings +- **[Translation guide](apps/web/CONTRIBUTING_TRANSLATIONS.md)** — contributing + translations via Crowdin +- **[Testing guide](TESTING.md)** — unit tests and the end-to-end device suite +- **[Code of Conduct](CODE_OF_CONDUCT.md)** +- **[Security policy](.github/SECURITY.md)** — please report vulnerabilities + privately + +## Quick start + +```bash +pnpm install +pnpm --filter ./apps/web dev +``` + +Before opening a pull request: + +```bash +pnpm run check:fix # lint + format +pnpm exec vitest run # unit tests +pnpm --filter ./apps/web run typecheck # types +``` + +CI runs the same lint, format, typecheck, test, and build steps, so running +them locally first is the fastest way to a green PR. + +> This file is a pointer so GitHub surfaces contribution guidance from the +> repository root. Substantive guidance belongs in +> [apps/web/CONTRIBUTING.md](apps/web/CONTRIBUTING.md).