diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 4049fdd..926c0c2 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -90,6 +90,7 @@ on: secrets: RELENG_GITHUB_TOKEN: required: true + description: "Temporary Homebrew tap write token only; release assets use the caller job token." APPLE_SIGNING_KEY_P12: required: true APPLE_SIGNING_KEY_P12_PASSWORD: @@ -396,7 +397,7 @@ jobs: GOPROXY: https://proxy.golang.org GOSUMDB: sum.golang.org permissions: - contents: read + contents: write # Create/upload the caller repository's GitHub release assets. id-token: write # <-- needed for cosign keyless (OIDC) outputs: s3_directory: ${{ steps.s3-directory.outputs.S3_DIRECTORY }} @@ -511,13 +512,14 @@ jobs: working-directory: _caller run: goreleaser release --clean --config ../_workflows/_generated/.goreleaser.binaries.yaml env: - GITHUB_TOKEN: ${{ secrets.RELENG_GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ github.token }} GORELEASER_CURRENT_TAG: ${{ inputs.tag }} AC_PASSWORD: ${{ secrets.AC_PASSWORD }} AC_PROVIDER: ${{ secrets.AC_PROVIDER }} # GoReleaser renders the formula (skip_upload) and this step writes it, # retrying when a concurrent release moved the tap's branch. + # The tap-scoped replacement token is deferred; this is the only legacy-token consumer. - name: Publish Homebrew formula if: inputs.brew == true working-directory: _workflows @@ -847,7 +849,7 @@ jobs: working-directory: _caller run: goreleaser release --clean --skip=publish --config ../_workflows/_generated/.goreleaser.windows.yaml env: - GITHUB_TOKEN: ${{ secrets.RELENG_GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ github.token }} GORELEASER_CURRENT_TAG: ${{ inputs.tag }} RELEASE_TAG: ${{ inputs.tag }} GORELEASER_KEY: ${{ secrets.GORELEASER_PRO_KEY }} @@ -1173,7 +1175,7 @@ jobs: working-directory: _caller run: goreleaser release --clean --config ../_workflows/_generated/.goreleaser.docker.yaml env: - GITHUB_TOKEN: ${{ secrets.RELENG_GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ github.token }} GORELEASER_CURRENT_TAG: ${{ inputs.tag }} COSIGN_EXPERIMENTAL: "1" @@ -1210,7 +1212,7 @@ jobs: working-directory: _caller run: goreleaser release --clean --config ../_workflows/_generated/.goreleaser.lambda.yaml env: - GITHUB_TOKEN: ${{ secrets.RELENG_GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ github.token }} GORELEASER_CURRENT_TAG: ${{ inputs.tag }} - name: Extract image digests from GoReleaser assets diff --git a/Makefile b/Makefile index 39dbefd..1751070 100644 --- a/Makefile +++ b/Makefile @@ -22,6 +22,7 @@ test-scripts: bash scripts/test-derive-iam-role-name.sh bash scripts/test-normalize-release-options.sh python3 scripts/test-release-config-templates.py + python3 scripts/test-release-tokens.py python3 scripts/test-check-image-locks.py python3 scripts/test-windows-pre-build-script.py python3 scripts/test-safe-relative-path.py diff --git a/docs/release-workflow.md b/docs/release-workflow.md index 61d5188..7c74cf7 100644 --- a/docs/release-workflow.md +++ b/docs/release-workflow.md @@ -238,7 +238,7 @@ Every GoReleaser invocation receives `GORELEASER_CURRENT_TAG` from the workflow Nine release jobs first check out `_workflows` at `${{ job.workflow_sha }}` with a SHA-pinned checkout action and `persist-credentials: false`. This fetch necessarily precedes the guard and, like the runner's own action downloads, -authenticates to github.com with the job's read-scoped `GITHUB_TOKEN` +authenticates to github.com with the job-scoped `GITHUB_TOKEN` (actions/checkout requires a token); the token is not persisted, the host is not behind the incident intermediates, and content integrity is bound to `job.workflow_sha`. This read-only infrastructure checkout provides the @@ -292,6 +292,51 @@ explicit inputs; job-scoped credentials remain available according to permission | docker/setup-qemu-action | `c7c53464625b32c7a7e944ae62b3e17d2b600130` | goreleaser-docker | Digest-pinned binfmt setup in an OIDC-enabled job; no explicit secret input. | | masci/datadog | `93a16378298f4e1980f13f6b412c0b8e66b96185` | notify-release-failure | `DATADOG_API_KEY` (Datadog org write key), only step `with: api-key`; non-signing, notification only. Best-effort (`continue-on-error: true`), five-minute job; event-only. Re-pin review: diff the action's tag-to-SHA source; re-pinning is manual (no Renovate tracking exists for workflow SHAs). | +### Release and Tap Token Boundaries + +The binaries job creates the caller repository's GitHub release and uploads its +archives using `${{ github.token }}`, with `contents: write` granted on that job +only. The other GoReleaser jobs disable GitHub release publication and retain +`contents: read`; they also receive the job token rather than an organization-wide +release token. Existing release metadata reads already use the job token and +remain read-only. Top-level workflow permissions stay empty. The job token is +scoped to the caller repository and expires with the job; `contents: write` is +not asset-only and can still modify repository contents and tags while valid. + +Before rollout, inventory every caller workflow's effective permissions. +A reusable workflow cannot elevate a read-only caller grant, and the Go caller +template has no explicit grant, so source alone does not establish fleet +defaults. Releases GoReleaser creates with the job token do not trigger caller +`on: release` workflows; inventory callers for such workflows as part of the +rollout. Callers must permit `contents: write` and `id-token: write` for the +release call, plus `actions: read` and `checks: read` for existing workflow-run +and failure-annotation reads; do not broaden unrelated jobs. For example: + +```yaml +jobs: + release: + permissions: + contents: write + id-token: write + actions: read + checks: read + uses: ConductorOne/github-workflows/.github/workflows/release.yaml@ +``` + +The Homebrew tap token migration is deferred pending an organization administrator: +provision a short-lived fine-grained `HOMEBREW_TAP_TOKEN` with `contents: write` +on `conductorone/homebrew-baton` only, and arrange rotation before switching the +tap step. Until that handoff, the `RELENG_GITHUB_TOKEN` secret declaration and its +single use in `Publish Homebrew formula` are the only permitted residuals; the +token is never passed to GoReleaser or metadata reads. This change does not create +or rotate secrets or alter the tap repository's rules. + +Offline tests check job-local permission and token wiring, including negative +controls for a read-only publishing job and a legacy-token GoReleaser regression. +They do not prove caller defaults or live uploads. Before rollout, run a +pre-release canary with Homebrew enabled, verify GitHub assets and the tap write, +then confirm that a deliberately read-only publishing grant rejects the upload. + ### Locked Release Tools `.github/actions/locked-tools/tool-locks.txt` identifies tool versions, artifact diff --git a/scripts/test-release-tokens.py b/scripts/test-release-tokens.py new file mode 100755 index 0000000..71d05ef --- /dev/null +++ b/scripts/test-release-tokens.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Check least-privilege token wiring without release credentials or network.""" +import copy +import json +from pathlib import Path +import subprocess +import unittest + +ROOT = Path(__file__).resolve().parent.parent + + +def load_yaml(path): + return json.loads(subprocess.check_output(["yq", ".", str(ROOT / path)], text=True)) + + +def token_errors(workflow): + errors = [] + if workflow["permissions"] != {}: + errors.append("workflow-wide permissions must remain empty") + residuals = [] + for name, job in workflow["jobs"].items(): + expected = "write" if name == "goreleaser-binaries" else "read" + if job["permissions"].get("contents") != expected: + errors.append(f"{name}: unexpected contents permission") + for step in job.get("steps", []): + if "secrets.RELENG_GITHUB_TOKEN" in json.dumps(step): + residuals.append((name, step["name"])) + if step.get("run", "").startswith("goreleaser release "): + if step.get("env", {}).get("GITHUB_TOKEN") != "${{ github.token }}": + errors.append(f"{name}: GoReleaser must use the job token") + if "gh api" in step.get("run", ""): + if step.get("env", {}).get("GH_TOKEN") != "${{ github.token }}": + errors.append(f"{name}: metadata reads must use the job token") + if residuals != [("goreleaser-binaries", "Publish Homebrew formula")]: + errors.append("only the deferred Homebrew tap step may consume the legacy token") + return errors + + +class ReleaseTokenTest(unittest.TestCase): + def setUp(self): + self.workflow = load_yaml(".github/workflows/release.yaml") + + def test_release_job_permissions_and_tokens(self): + self.assertEqual(token_errors(self.workflow), []) + release_steps = [step for job in self.workflow["jobs"].values() for step in job.get("steps", []) + if step.get("run", "").startswith("goreleaser release ")] + self.assertEqual(len(release_steps), 4) + tap = next(step for step in self.workflow["jobs"]["goreleaser-binaries"]["steps"] + if step["name"] == "Publish Homebrew formula") + self.assertEqual(tap["if"], "inputs.brew == true") + self.assertEqual(tap["env"]["GITHUB_TOKEN"], "${{ secrets.RELENG_GITHUB_TOKEN }}") + + def test_only_binaries_publish_github_release(self): + binaries = load_yaml("templates/.goreleaser-binaries-template.yaml.tmpl") + self.assertNotEqual(binaries["release"].get("disable"), True) + self.assertEqual(binaries["brews"][0]["skip_upload"], "true") + for template in ("windows", "docker-oci", "docker-lambda"): + config = load_yaml(f"templates/.goreleaser-{template}-template.yaml.tmpl") + self.assertTrue(config["release"]["disable"]) + + def test_read_only_publisher_negative_control(self): + changed = copy.deepcopy(self.workflow) + changed["jobs"]["goreleaser-binaries"]["permissions"]["contents"] = "read" + self.assertIn("goreleaser-binaries: unexpected contents permission", token_errors(changed)) + + def test_no_broader_grants_negative_controls(self): + for name in self.workflow["jobs"]: + if name == "goreleaser-binaries": + continue + changed = copy.deepcopy(self.workflow) + changed["jobs"][name]["permissions"]["contents"] = "write" + self.assertTrue(token_errors(changed), name) + changed = copy.deepcopy(self.workflow) + changed["permissions"] = {"contents": "write"} + self.assertTrue(token_errors(changed)) + + def test_legacy_release_token_negative_control(self): + for name, job in self.workflow["jobs"].items(): + for index, step in enumerate(job.get("steps", [])): + if not step.get("run", "").startswith("goreleaser release "): + continue + changed = copy.deepcopy(self.workflow) + changed["jobs"][name]["steps"][index]["env"]["GITHUB_TOKEN"] = "${{ secrets.RELENG_GITHUB_TOKEN }}" + self.assertTrue(token_errors(changed), name) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test-tool-integrity.py b/scripts/test-tool-integrity.py index 271adf3..d728b53 100755 --- a/scripts/test-tool-integrity.py +++ b/scripts/test-tool-integrity.py @@ -685,7 +685,8 @@ def test_workflow_security_wiring(self) -> None: }) self.assertNotIn("if", bootstrap) self.assertNotIn("continue-on-error", bootstrap) - self.assertEqual(job["permissions"]["contents"], "read") + expected_contents = "write" if job_name == "goreleaser-binaries" else "read" + self.assertEqual(job["permissions"]["contents"], expected_contents) checkout_count += 1 self.assertEqual(guards, [1]) self.assertEqual(steps[1].get("with", {}), {}, "use enforce/true/true/fail defaults")