diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 91ca55c8..6bc710af 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -19,6 +19,20 @@ through the repository API. Their profiles are `release_notifications`, `flathub selections disable that workflow group; invalid values or failed property reads fail the check. These runtime checks are independent of the file replicator's `sync_mode` setting. +The `__call-organization.yml` reusable workflow exports an `allowed` output based on the caller's +owner and the trusted `LizardByte/.github/master/organizations.json` registry. It needs no custom secrets +or repository permissions and fails if the registry cannot be read or validated. New organizations only +need to be added to that registry. Consumer workflows use this check for publishing, coverage uploads, +and organization automation; ordinary test builds remain available to unregistered forks. The controller's +own maintenance jobs retain their repository restrictions. + +Shared publishing destinations remain in LizardByte, including the website, Homebrew tap, pacman repository, +Docker/GHCR namespace, npm scope, and WinGet identifiers. Docker callers can override `image_namespace` if +they publish elsewhere. WinGet uses the configured bot's fork. Cross-organization GitHub npm publishing +requires `GH_TOKEN` with `write:packages` access to the existing LizardByte packages; the replicated wrapper +passes `GH_BOT_TOKEN`. npmjs trusted-publisher bindings and external service installations must also be +updated for the destination owner when transferring a repository. + Repositories excluded from the GitLab mirror are listed in `.github/repositories-to-ignore.json`. The GitLab mirror also excludes GitHub temporary security advisory forks matching diff --git a/.github/workflows/__call-ci-node.yml b/.github/workflows/__call-ci-node.yml index f725554c..1930866f 100644 --- a/.github/workflows/__call-ci-node.yml +++ b/.github/workflows/__call-ci-node.yml @@ -23,10 +23,16 @@ on: required: false jobs: + organization: + uses: ./.github/workflows/__call-organization.yml + setup_release: + needs: organization name: Setup Release outputs: - publish_release: ${{ steps.setup_release.outputs.publish_release }} + publish_release: >- + ${{ needs.organization.outputs.allowed == 'true' && + steps.setup_release.outputs.publish_release == 'true' }} release_body: ${{ steps.setup_release.outputs.release_body }} release_generate_release_notes: ${{ steps.setup_release.outputs.release_generate_release_notes }} release_tag: ${{ steps.setup_release.outputs.release_tag }} @@ -47,6 +53,7 @@ jobs: build: needs: - setup_release + - organization permissions: contents: read runs-on: ubuntu-latest @@ -98,7 +105,7 @@ jobs: if: >- always() && (steps.test.outcome == 'success' || steps.test.outcome == 'failure') && - startsWith(github.repository, 'LizardByte/') + needs.organization.outputs.allowed == 'true' uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 with: disable_search: true @@ -113,7 +120,7 @@ jobs: if: >- always() && (steps.test.outcome == 'success' || steps.test.outcome == 'failure') && - startsWith(github.repository, 'LizardByte/') + needs.organization.outputs.allowed == 'true' uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 with: fail_ci_if_error: true diff --git a/.github/workflows/__call-docker.yml b/.github/workflows/__call-docker.yml index 9aa64c44..db75ad02 100644 --- a/.github/workflows/__call-docker.yml +++ b/.github/workflows/__call-docker.yml @@ -20,6 +20,11 @@ on: pull_request: workflow_call: inputs: + image_namespace: + description: 'Existing namespace for Docker Hub and GHCR images.' + required: false + default: 'LizardByte' + type: string docker_hub_username: description: 'Docker Hub username to use for the workflow.' required: false @@ -60,7 +65,11 @@ on: required: false jobs: + organization: + uses: ./.github/workflows/__call-organization.yml + check_dockerfiles: + needs: organization name: Plan Docker builds permissions: contents: read @@ -173,13 +182,16 @@ jobs: - name: Set image name id: additional_outputs if: steps.find.outputs.has_bake == 'true' + env: + IMAGE_NAMESPACE: ${{ inputs.image_namespace || 'LizardByte' }} + REPOSITORY_NAME: ${{ github.event.repository.name }} run: | # set outputs for later jobs - REPOSITORY="${{ github.repository }}" - BASE_TAG=$(echo ${REPOSITORY} | tr '[:upper:]' '[:lower:]') + BASE_TAG=$(printf '%s/%s' "${IMAGE_NAMESPACE}" "${REPOSITORY_NAME}" | tr '[:upper:]' '[:lower:]') echo "base_tag=${BASE_TAG}" >> "${GITHUB_OUTPUT}" outputs: + publish_release: ${{ needs.organization.outputs.allowed == 'true' && inputs.publish_release == 'true' }} bake_build_matrix: ${{ steps.find.outputs.bake_build_matrix }} bake_target_matrix: ${{ steps.find.outputs.bake_target_matrix }} base_tag: ${{ steps.additional_outputs.outputs.base_tag }} @@ -197,13 +209,13 @@ jobs: environment: name: >- ${{ - inputs.publish_release == 'true' && + needs.check_dockerfiles.outputs.publish_release == 'true' && format('docker{0}', matrix.tag) || null }} url: >- ${{ - inputs.publish_release == 'true' && + needs.check_dockerfiles.outputs.publish_release == 'true' && format('https://hub.docker.com/r/{0}/tags?name={1}', needs.check_dockerfiles.outputs.base_tag, matrix.tag) || null @@ -230,7 +242,7 @@ jobs: BRANCH: ${{ github.ref }} COMMIT: ${{ inputs.release_commit }} GH_EVENT_REPOSITORY_CLONE_URL: ${{ github.event.repository.clone_url }} - PUBLISH_RELEASE: ${{ inputs.publish_release }} + PUBLISH_RELEASE: ${{ needs.check_dockerfiles.outputs.publish_release }} RELEASE_VERSION: ${{ inputs.release_version }} USE_CACHE: ${{ matrix.cache }} shell: bash @@ -280,14 +292,14 @@ jobs: uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Log in to Docker Hub - if: inputs.publish_release == 'true' # PRs do not have access to secrets + if: needs.check_dockerfiles.outputs.publish_release == 'true' # PRs do not have access to secrets uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0 with: username: ${{ inputs.docker_hub_username }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Log in to the Container registry - if: inputs.publish_release == 'true' # PRs do not have access to secrets + if: needs.check_dockerfiles.outputs.publish_release == 'true' # PRs do not have access to secrets uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0 with: registry: ghcr.io @@ -340,7 +352,7 @@ jobs: if-no-files-found: error - name: Export digest - if: inputs.publish_release == 'true' + if: needs.check_dockerfiles.outputs.publish_release == 'true' env: BUILD_METADATA: ${{ steps.build.outputs.metadata }} shell: bash @@ -356,7 +368,7 @@ jobs: touch "digests/${digest#sha256:}" - name: Upload digest - if: inputs.publish_release == 'true' + if: needs.check_dockerfiles.outputs.publish_release == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: docker-digests-${{ matrix.target }}-${{ matrix.platform_pair }} @@ -366,7 +378,7 @@ jobs: docker_bake_manifest: name: Manifest - if: inputs.publish_release == 'true' + if: needs.check_dockerfiles.outputs.publish_release == 'true' needs: - check_dockerfiles - docker_bake @@ -471,6 +483,7 @@ jobs: release: name: Release if: > + needs.check_dockerfiles.outputs.publish_release == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/master' runs-on: ubuntu-latest diff --git a/.github/workflows/__call-organization.yml b/.github/workflows/__call-organization.yml new file mode 100644 index 00000000..a8287aa4 --- /dev/null +++ b/.github/workflows/__call-organization.yml @@ -0,0 +1,35 @@ +--- +name: Check organization (called) +permissions: {} + +on: + workflow_call: + outputs: + allowed: + description: Whether the caller belongs to the shared organization registry. + value: ${{ jobs.check.outputs.allowed }} + +jobs: + check: + runs-on: ubuntu-latest + outputs: + allowed: ${{ steps.registry.outputs.allowed }} + steps: + - name: Check trusted organization registry + id: registry + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const response = await fetch( + 'https://raw.githubusercontent.com/LizardByte/.github/master/organizations.json', + {signal: AbortSignal.timeout(15000)}, + ); + if (!response.ok) throw new Error(`Organization registry request failed: HTTP ${response.status}`); + const organizations = await response.json(); + if (!Array.isArray(organizations) || !organizations.length || organizations.some(name => + typeof name !== 'string' || !/^[a-z\d][a-z\d-]*$/i.test(name))) { + throw new Error('Invalid organization registry'); + } + const allowed = organizations.some(name => name.toLowerCase() === context.repo.owner.toLowerCase()); + core.info(`Organization ${context.repo.owner}: ${allowed ? 'registered' : 'not registered'}`); + core.setOutput('allowed', allowed); diff --git a/.github/workflows/__call-release-notifier.yml b/.github/workflows/__call-release-notifier.yml index 2b565c41..feb271f6 100644 --- a/.github/workflows/__call-release-notifier.yml +++ b/.github/workflows/__call-release-notifier.yml @@ -18,9 +18,13 @@ on: required: true jobs: + organization: + uses: ./.github/workflows/__call-organization.yml + update-blog: + needs: organization name: Update blog - if: github.repository_owner == 'LizardByte' + if: needs.organization.outputs.allowed == 'true' permissions: contents: read runs-on: ubuntu-latest @@ -28,7 +32,7 @@ jobs: name: release-announcement url: ${{ steps.create-pr.outputs.pull-request-url }} env: - BLOG_REPO: ${{ github.repository_owner }}/${{ github.repository_owner }}.github.io + BLOG_REPO: LizardByte/LizardByte.github.io steps: - name: Check workflow profile env: diff --git a/.github/workflows/__call-top-issues.yml b/.github/workflows/__call-top-issues.yml index 9d25c328..5563e6c8 100644 --- a/.github/workflows/__call-top-issues.yml +++ b/.github/workflows/__call-top-issues.yml @@ -8,7 +8,12 @@ on: workflow_call: jobs: + organization: + uses: ./.github/workflows/__call-organization.yml + top-issues: + needs: organization + if: needs.organization.outputs.allowed == 'true' name: Top issues permissions: contents: read diff --git a/.github/workflows/__call-update-changelog.yml b/.github/workflows/__call-update-changelog.yml index 85ad985a..c10a82f5 100644 --- a/.github/workflows/__call-update-changelog.yml +++ b/.github/workflows/__call-update-changelog.yml @@ -12,7 +12,12 @@ on: required: true jobs: + organization: + uses: ./.github/workflows/__call-organization.yml + update-changelog: + needs: organization + if: needs.organization.outputs.allowed == 'true' name: Update Changelog permissions: contents: read diff --git a/.github/workflows/__call-update-docs.yml b/.github/workflows/__call-update-docs.yml index 0ac66929..453f6167 100644 --- a/.github/workflows/__call-update-docs.yml +++ b/.github/workflows/__call-update-docs.yml @@ -16,7 +16,11 @@ on: required: true jobs: + organization: + uses: ./.github/workflows/__call-organization.yml + update-docs: + needs: organization environment: name: ReadTheDocs url: https://${{ steps.get_slug.outputs.RTD_SLUG }}.readthedocs.io//latest @@ -25,6 +29,7 @@ jobs: RTD_TOKEN: ${{ secrets.READTHEDOCS_TOKEN }} TAG: ${{ github.event.release.tag_name }} if: >- + needs.organization.outputs.allowed == 'true' && !github.event.release.draft permissions: {} runs-on: ubuntu-latest diff --git a/.github/workflows/__call-update-flathub-repo.yml b/.github/workflows/__call-update-flathub-repo.yml index 082e4b55..1983be4f 100644 --- a/.github/workflows/__call-update-flathub-repo.yml +++ b/.github/workflows/__call-update-flathub-repo.yml @@ -18,7 +18,12 @@ on: required: true jobs: + organization: + uses: ./.github/workflows/__call-organization.yml + update-flathub-repo: + needs: organization + if: needs.organization.outputs.allowed == 'true' environment: name: Flathub url: ${{ steps.create-pr.outputs.pull-request-url }} diff --git a/.github/workflows/__call-update-homebrew-repo.yml b/.github/workflows/__call-update-homebrew-repo.yml index cd614fd5..a1bb3a3f 100644 --- a/.github/workflows/__call-update-homebrew-repo.yml +++ b/.github/workflows/__call-update-homebrew-repo.yml @@ -18,13 +18,18 @@ on: required: true jobs: + organization: + uses: ./.github/workflows/__call-organization.yml + update-homebrew-repo: + needs: organization + if: needs.organization.outputs.allowed == 'true' permissions: contents: read runs-on: ubuntu-latest environment: name: Homebrew - url: https://github.com/${{ github.repository_owner }}/homebrew-homebrew/commits + url: https://github.com/LizardByte/homebrew-homebrew/commits steps: - name: Check workflow profile id: check-profile @@ -68,5 +73,6 @@ jobs: git_email: ${{ secrets.GH_EMAIL }} git_username: ${{ inputs.gh_username }} publish: true + org_homebrew_repo: LizardByte/homebrew-homebrew token: ${{ secrets.GH_TOKEN }} validate: false diff --git a/.github/workflows/__call-update-npm.yml b/.github/workflows/__call-update-npm.yml index 7cbff65c..55a5664b 100644 --- a/.github/workflows/__call-update-npm.yml +++ b/.github/workflows/__call-update-npm.yml @@ -4,6 +4,10 @@ permissions: {} on: workflow_call: + secrets: + GH_TOKEN: + description: Token with write access to the existing GitHub Packages namespace. + required: false inputs: release_version: description: 'Version to publish (v prefix will be stripped automatically).' @@ -11,7 +15,12 @@ on: type: string jobs: + organization: + uses: ./.github/workflows/__call-organization.yml + publish-npm: + needs: organization + if: needs.organization.outputs.allowed == 'true' permissions: contents: read id-token: write # required for provenance and OIDC @@ -36,9 +45,13 @@ jobs: - name: Setup token env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ github.repository_owner == 'LizardByte' && secrets.GITHUB_TOKEN || secrets.GH_TOKEN }} run: | if [[ "${{ matrix.environment }}" == "npm-gpr" ]]; then + if [[ -z "${GH_TOKEN}" ]]; then + echo "::error::GitHub Packages publishing requires GH_TOKEN with write:packages access to LizardByte." + exit 1 + fi echo "NODE_AUTH_TOKEN=${GH_TOKEN}" >> "${GITHUB_ENV}" fi @@ -72,10 +85,10 @@ jobs: - name: Set environment output id: environment run: | + package_name=$(node -p "require('./package.json').name") if [[ "${{ matrix.environment }}" == "npm-gpr" ]]; then - url="https://github.com/${{ github.repository }}/pkgs/npm/${{ github.event.repository.name }}" + url="https://github.com/orgs/LizardByte/packages/npm/${package_name#@lizardbyte/}" elif [[ "${{ matrix.environment }}" == "npmjs" ]]; then - package_name=$(node -p "require('./package.json').name") url="https://www.npmjs.com/package/${package_name}" fi echo "url=${url}" >> "${GITHUB_OUTPUT}" diff --git a/.github/workflows/__call-update-pacman-repo.yml b/.github/workflows/__call-update-pacman-repo.yml index 191f7f8d..de704239 100644 --- a/.github/workflows/__call-update-pacman-repo.yml +++ b/.github/workflows/__call-update-pacman-repo.yml @@ -18,7 +18,12 @@ on: required: true jobs: + organization: + uses: ./.github/workflows/__call-organization.yml + update-pacman-repo: + needs: organization + if: needs.organization.outputs.allowed == 'true' permissions: contents: read runs-on: ubuntu-latest @@ -26,7 +31,7 @@ jobs: name: pacman-repo url: ${{ steps.create-pr.outputs.pull-request-url }} env: - PACMAN_REPO: ${{ github.repository_owner }}/pacman-repo + PACMAN_REPO: LizardByte/pacman-repo steps: - name: Check workflow profile id: check-profile diff --git a/.github/workflows/__call-update-winget-repo.yml b/.github/workflows/__call-update-winget-repo.yml index bf4d15b4..c6218f23 100644 --- a/.github/workflows/__call-update-winget-repo.yml +++ b/.github/workflows/__call-update-winget-repo.yml @@ -18,7 +18,12 @@ on: required: true jobs: + organization: + uses: ./.github/workflows/__call-organization.yml + update-winget-repo: + needs: organization + if: needs.organization.outputs.allowed == 'true' permissions: contents: read runs-on: ubuntu-latest @@ -64,7 +69,7 @@ jobs: fromJson(steps.download.outputs.downloaded_files)[0] env: GH_TOKEN: ${{ secrets.GH_TOKEN }} - WINGET_FORK: ${{ github.repository_owner }}/winget-pkgs + WINGET_FORK: ${{ vars.GH_BOT_NAME }}/winget-pkgs run: gh repo sync "$WINGET_FORK" --branch master - name: Release to WinGet @@ -75,6 +80,6 @@ jobs: uses: vedantmgoyal9/winget-releaser@b7370d4d152efdce803a26cc6ca9eee58cb2ad54 # v2 with: fork-user: "${{ vars.GH_BOT_NAME }}" - identifier: "${{ github.repository_owner }}.${{ github.event.repository.name }}" + identifier: "LizardByte.${{ github.event.repository.name }}" release-tag: "${{ github.event.release.tag_name }}" token: "${{ secrets.GH_TOKEN }}" diff --git a/.github/workflows/__validate-renovate-config.yml b/.github/workflows/__validate-config.yml similarity index 73% rename from .github/workflows/__validate-renovate-config.yml rename to .github/workflows/__validate-config.yml index 394dc6eb..d942d4b0 100644 --- a/.github/workflows/__validate-renovate-config.yml +++ b/.github/workflows/__validate-config.yml @@ -3,9 +3,9 @@ # Don't make changes to this file in this repo as they will be overwritten with changes made to the same file in # the above-mentioned repo. -# Validate Renovate config files. +# Validate Dependabot and Renovate configuration and run repository tests. -name: validate renovate config +name: Configuration validation and tests permissions: {} on: @@ -16,7 +16,7 @@ concurrency: cancel-in-progress: true jobs: - validate-renovate: + validate: permissions: contents: read runs-on: ubuntu-latest @@ -24,7 +24,7 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Find additional files + - name: Find Renovate configuration files id: find-files run: | files=$(find . -type f \( -iname "renovate*.json*" -o -ipath "./renovate/*.json*" \)) @@ -35,17 +35,23 @@ jobs: echo found=${files} >> "${GITHUB_OUTPUT}" - name: Setup Node - if: steps.find-files.outputs.found != '' uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: latest - name: Install npm dependencies - if: steps.find-files.outputs.found != '' + id: install run: npm ci --ignore-scripts - - name: renovate config validator - if: steps.find-files.outputs.found != '' + - name: Validate Dependabot configuration + if: ${{ !cancelled() && steps.install.outcome == 'success' }} + run: npm run validate-dependabot + + - name: Validate Renovate configuration + if: >- + !cancelled() && + steps.install.outcome == 'success' && + steps.find-files.outputs.found != '' run: | # disable error exit code set +e @@ -67,6 +73,6 @@ jobs: # exit with error if any file failed exit ${status} - - name: renovate extraction tests - if: steps.find-files.outputs.found != '' + - name: Run tests + if: ${{ !cancelled() && steps.install.outcome == 'success' }} run: npm test diff --git a/.github/workflows/__validate-dependabot-config.yml b/.github/workflows/__validate-dependabot-config.yml deleted file mode 100644 index 0c791361..00000000 --- a/.github/workflows/__validate-dependabot-config.yml +++ /dev/null @@ -1,36 +0,0 @@ ---- -# This workflow is centrally managed in https://github.com//.github/ -# Don't make changes to this file in this repo as they will be overwritten with changes made to the same file in -# the above-mentioned repo. - -# Validate Dependabot config files. - -name: validate dependabot config -permissions: {} - -on: - pull_request: - -concurrency: - group: "${{ github.workflow }}-${{ github.ref }}" - cancel-in-progress: true - -jobs: - validate-dependabot: - permissions: - contents: read - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Setup Node - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: latest - - - name: Install dependencies - run: npm ci --ignore-scripts - - - name: Validate dependabot config - run: npm run validate-dependabot diff --git a/.github/workflows/_release-notifier.yml b/.github/workflows/_release-notifier.yml index 7a30fa1a..9667efbb 100644 --- a/.github/workflows/_release-notifier.yml +++ b/.github/workflows/_release-notifier.yml @@ -17,7 +17,6 @@ jobs: update-blog: name: Update blog uses: LizardByte/.github/.github/workflows/__call-release-notifier.yml@master - if: github.repository_owner == 'LizardByte' permissions: contents: read with: diff --git a/.github/workflows/_top-issues.yml b/.github/workflows/_top-issues.yml index 3072822c..83a8bf06 100644 --- a/.github/workflows/_top-issues.yml +++ b/.github/workflows/_top-issues.yml @@ -21,7 +21,6 @@ jobs: top-issues: name: Top issues uses: LizardByte/.github/.github/workflows/__call-top-issues.yml@master - if: github.repository_owner == 'LizardByte' permissions: contents: read issues: write diff --git a/.github/workflows/_update-changelog.yml b/.github/workflows/_update-changelog.yml index 314caf7e..e7b9dc6a 100644 --- a/.github/workflows/_update-changelog.yml +++ b/.github/workflows/_update-changelog.yml @@ -23,7 +23,6 @@ jobs: name: Update Changelog uses: LizardByte/.github/.github/workflows/__call-update-changelog.yml@master if: >- - github.repository_owner == 'LizardByte' && (github.event_name == 'workflow_dispatch' || (!github.event.release.prerelease && !github.event.release.draft)) permissions: diff --git a/.github/workflows/_update-docs.yml b/.github/workflows/_update-docs.yml index 22c2a517..dd890841 100644 --- a/.github/workflows/_update-docs.yml +++ b/.github/workflows/_update-docs.yml @@ -27,7 +27,6 @@ jobs: update-docs: name: Update docs uses: LizardByte/.github/.github/workflows/__call-update-docs.yml@master - if: github.repository_owner == 'LizardByte' permissions: {} with: readthedocs_slug: ${{ vars.READTHEDOCS_SLUG }} diff --git a/.github/workflows/_update-flathub-repo.yml b/.github/workflows/_update-flathub-repo.yml index 4b75a248..3fd24093 100644 --- a/.github/workflows/_update-flathub-repo.yml +++ b/.github/workflows/_update-flathub-repo.yml @@ -23,7 +23,6 @@ jobs: update-flathub-repo: name: Update Flathub Repo uses: LizardByte/.github/.github/workflows/__call-update-flathub-repo.yml@master - if: github.repository_owner == 'LizardByte' permissions: contents: read with: diff --git a/.github/workflows/_update-homebrew-repo.yml b/.github/workflows/_update-homebrew-repo.yml index cdd96ddb..5c796be7 100644 --- a/.github/workflows/_update-homebrew-repo.yml +++ b/.github/workflows/_update-homebrew-repo.yml @@ -23,7 +23,6 @@ jobs: update-homebrew-repo: name: Update Homebrew repo uses: LizardByte/.github/.github/workflows/__call-update-homebrew-repo.yml@master - if: github.repository_owner == 'LizardByte' permissions: contents: read with: diff --git a/.github/workflows/_update-npm.yml b/.github/workflows/_update-npm.yml index a267db10..018a5a8f 100644 --- a/.github/workflows/_update-npm.yml +++ b/.github/workflows/_update-npm.yml @@ -23,10 +23,11 @@ jobs: update-npm: name: Update NPM uses: LizardByte/.github/.github/workflows/__call-update-npm.yml@master - if: github.repository_owner == 'LizardByte' permissions: contents: read id-token: write # required for provenance and OIDC packages: write + secrets: + GH_TOKEN: ${{ secrets.GH_BOT_TOKEN }} with: release_version: ${{ github.event.release.tag_name }} diff --git a/.github/workflows/_update-pacman-repo.yml b/.github/workflows/_update-pacman-repo.yml index 89a67aae..16561abb 100644 --- a/.github/workflows/_update-pacman-repo.yml +++ b/.github/workflows/_update-pacman-repo.yml @@ -23,7 +23,6 @@ jobs: update-pacman-repo: name: Update pacman repo uses: LizardByte/.github/.github/workflows/__call-update-pacman-repo.yml@master - if: github.repository_owner == 'LizardByte' permissions: contents: read with: diff --git a/.github/workflows/_update-winget-repo.yml b/.github/workflows/_update-winget-repo.yml index 9bb4d8e9..5aecabfa 100644 --- a/.github/workflows/_update-winget-repo.yml +++ b/.github/workflows/_update-winget-repo.yml @@ -23,7 +23,6 @@ jobs: update-winget-repo: name: Update Winget repo uses: LizardByte/.github/.github/workflows/__call-update-winget-repo.yml@master - if: github.repository_owner == 'LizardByte' permissions: contents: read secrets: diff --git a/tests/organization-authorization.test.mjs b/tests/organization-authorization.test.mjs new file mode 100644 index 00000000..87491e90 --- /dev/null +++ b/tests/organization-authorization.test.mjs @@ -0,0 +1,145 @@ +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import test from 'node:test'; + +const registryUrl = 'https://raw.githubusercontent.com/LizardByte/.github/master/organizations.json'; +const workflow = readFileSync('.github/workflows/__call-organization.yml', 'utf8').replaceAll('\r\n', '\n'); +const body = workflow.split(' script: |\n')[1].split('\n').map(line => line.slice(12)).join('\n'); +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; +const execute = new AsyncFunction('fetch', 'context', 'core', 'AbortSignal', body); +const organizations = JSON.parse(readFileSync('organizations.json', 'utf8')); + +async function authorize(owner, {registry = organizations, status = 200, error, payload = {}} = {}) { + const outputs = {}; + const signal = {}; + const core = {info() {}, setOutput(name, value) { outputs[name] = value; }}; + const fetch = async (url, options) => { + assert.equal(url, registryUrl); + assert.deepEqual(options, {signal}); + if (error) throw error; + return {ok: status === 200, status, json: async () => registry}; + }; + const AbortSignal = {timeout(milliseconds) { + assert.equal(milliseconds, 15000); + return signal; + }}; + await execute(fetch, {repo: {owner}, payload}, core, AbortSignal); + return outputs; +} + +test('all registered owners, including destination orgs, are authorized without duplicating the registry', async () => { + for (const owner of organizations) { + assert.deepEqual(await authorize(owner), {allowed: true}); + assert.deepEqual(await authorize(owner.toUpperCase()), {allowed: true}); + } + assert.deepEqual(await authorize('NewProject', {registry: [...organizations, 'NewProject']}), {allowed: true}); +}); + +test('an unregistered fork or lookalike owner cannot authorize itself through event payload data', async () => { + for (const owner of ['external-fork', 'LizardByte-extra', 'notSunshineStream']) { + assert.deepEqual(await authorize(owner, {payload: {repository: {owner: {login: 'LizardByte'}}}}), {allowed: false}); + } + // A fork PR against a registered base repo still runs ordinary CI under that base owner. + assert.deepEqual(await authorize('LizardByte', {payload: {pull_request: {head: {repo: {fork: true}}}}}), {allowed: true}); +}); + +test('missing, invalid, inaccessible, and timed-out registries fail closed', async () => { + for (const registry of [[], null, {}, 'LizardByte', [42], ['LizardByte', 'bad/org']]) { + await assert.rejects(authorize('LizardByte', {registry}), /Invalid organization registry/); + } + await assert.rejects(authorize('LizardByte', {status: 503}), /HTTP 503/); + await assert.rejects(authorize('LizardByte', {error: new Error('Request timed out')}), /Request timed out/); +}); + +test('authorization requires no repository permissions or custom secrets and exports a reusable result', () => { + assert.match(workflow, /permissions: \{\}/); + assert.doesNotMatch(workflow, /secrets:|inputs:|Authorization:|actions\/checkout/); + assert.match(workflow, /value: \$\{\{ jobs.check.outputs.allowed \}\}/); + assert.match(workflow, /allowed: \$\{\{ steps.registry.outputs.allowed \}\}/); +}); + +const gated = [ + ['__call-release-notifier.yml', 'update-blog'], + ['__call-top-issues.yml', 'top-issues'], + ['__call-update-changelog.yml', 'update-changelog'], + ['__call-update-docs.yml', 'update-docs'], + ['__call-update-flathub-repo.yml', 'update-flathub-repo'], + ['__call-update-homebrew-repo.yml', 'update-homebrew-repo'], + ['__call-update-npm.yml', 'publish-npm'], + ['__call-update-pacman-repo.yml', 'update-pacman-repo'], + ['__call-update-winget-repo.yml', 'update-winget-repo'], +]; + +for (const [file, job] of gated) { + test(`${file} checks the trusted registry before any provider job`, () => { + const source = readFileSync(`.github/workflows/${file}`, 'utf8'); + assert.match(source, /organization:\s+uses: \.\/.github\/workflows\/__call-organization.yml/); + const start = source.indexOf(` ${job}:`); + assert.notEqual(start, -1); + const jobSource = source.slice(start); + assert.match(jobSource, /needs: organization/); + assert.match(jobSource, /if:[\s>\-]*needs.organization.outputs.allowed == 'true'/); + assert.doesNotMatch(source, /if:[^\n]*repository_owner == 'LizardByte'/); + }); +} + +test('consumer wrappers delegate owner authorization while retaining event and controller-only guards', () => { + for (const [file] of gated) { + const wrapper = file.replace('__call-', '_'); + const source = readFileSync(`.github/workflows/${wrapper}`, 'utf8'); + assert.doesNotMatch(source, /repository_owner == 'LizardByte'/); + } + const changelog = readFileSync('.github/workflows/_update-changelog.yml', 'utf8'); + assert.match(changelog, /!github.event.release.prerelease && !github.event.release.draft/); + const controller = readFileSync('.github/workflows/__global-replicator.yml', 'utf8'); + assert.match(controller, /github.repository == 'LizardByte\/\.github'/); +}); + +test('Node coverage and releases require registration while fork test builds remain enabled', () => { + const source = readFileSync('.github/workflows/__call-ci-node.yml', 'utf8'); + assert.match(source, /needs.organization.outputs.allowed == 'true' &&\s+steps.setup_release.outputs.publish_release == 'true'/); + assert.equal(source.match(/needs.organization.outputs.allowed == 'true'/g).length, 3); + const build = source.slice(source.indexOf(' build:'), source.indexOf(' release:')); + assert.doesNotMatch(build.slice(0, build.indexOf(' steps:')), /if:/); + assert.match(build, /- organization/); +}); + +test('Docker publishing uses an authorized plan flag rather than a caller-controlled input', () => { + const source = readFileSync('.github/workflows/__call-docker.yml', 'utf8'); + assert.match(source, /publish_release: \$\{\{ needs.organization.outputs.allowed == 'true' && inputs.publish_release == 'true' \}\}/); + const builds = source.slice(source.indexOf(' docker_bake:')); + assert.doesNotMatch(builds, /inputs.publish_release/); + assert.match(builds, /PUBLISH_RELEASE: \$\{\{ needs.check_dockerfiles.outputs.publish_release \}\}/); +}); + +test('shared publishing destinations and installed package identities stay under LizardByte', () => { + const expected = [ + ['__call-release-notifier.yml', 'BLOG_REPO: LizardByte/LizardByte.github.io'], + ['__call-update-pacman-repo.yml', 'PACMAN_REPO: LizardByte/pacman-repo'], + ['__call-update-homebrew-repo.yml', 'org_homebrew_repo: LizardByte/homebrew-homebrew'], + ['__call-update-winget-repo.yml', 'LizardByte.${{ github.event.repository.name }}'], + ['__call-update-npm.yml', "scope: '@lizardbyte'"], + ]; + for (const [file, value] of expected) { + assert.ok(readFileSync(`.github/workflows/${file}`, 'utf8').includes(value), file); + } + const winget = readFileSync('.github/workflows/__call-update-winget-repo.yml', 'utf8'); + assert.match(winget, /WINGET_FORK: \$\{\{ vars.GH_BOT_NAME \}\}\/winget-pkgs/); +}); + +test('container names retain the existing namespace independently of the repository owner', () => { + const source = readFileSync('.github/workflows/__call-docker.yml', 'utf8'); + const step = source.slice(source.indexOf(' - name: Set image name'), source.indexOf(' outputs:')); + assert.match(step, /IMAGE_NAMESPACE: \$\{\{ inputs.image_namespace \|\| 'LizardByte' \}\}/); + assert.match(step, /REPOSITORY_NAME: \$\{\{ github.event.repository.name \}\}/); + assert.match(step, /printf '%s\/%s' "\$\{IMAGE_NAMESPACE\}" "\$\{REPOSITORY_NAME\}"/); + assert.doesNotMatch(step, /github.repository_owner|github.repository \}\}/); +}); + +test('cross-org npm publishing receives a bot token while same-org publishing retains GITHUB_TOKEN', () => { + const source = readFileSync('.github/workflows/__call-update-npm.yml', 'utf8'); + assert.match(source, /GH_TOKEN: \$\{\{ github.repository_owner == 'LizardByte' && secrets.GITHUB_TOKEN \|\| secrets.GH_TOKEN \}\}/); + assert.match(source, /if \[\[ -z "\$\{GH_TOKEN\}" \]\]; then/); + const wrapper = readFileSync('.github/workflows/_update-npm.yml', 'utf8'); + assert.match(wrapper, /GH_TOKEN: \$\{\{ secrets.GH_BOT_TOKEN \}\}/); +});