Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Suggestion (medium confidence): If a caller grants only contents: read, for example through a read-only org default, GitHub rejects the whole called workflow at startup. The error is "nested job is requesting 'contents: write'", so this is not limited to the binaries job, and it affects every caller that tracks this ref as soon as it merges. The PR body covers this, but the guard is only prose. Consider merging only after the caller-template PR lands, or tagging this as a breaking release so callers that pin a ref opt in.

id-token: write # <-- needed for cosign keyless (OIDC)
outputs:
s3_directory: ${{ steps.s3-directory.outputs.S3_DIRECTORY }}
Expand Down Expand Up @@ -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 }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Suggestion (low-medium confidence): For plain tag pushes, release-preflight reports prerelease=absent and GoReleaser creates the GitHub Release itself. A release created with github.token does not fire release: events, so any caller workflow listening for release: [published|created] silently stops running (it did fire when the release was made with the PAT). Please add this to the caller inventory and docs, or check that no caller depends on it.

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
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 46 additions & 1 deletion docs/release-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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@<reviewed-commit-sha>
```

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
Expand Down
88 changes: 88 additions & 0 deletions scripts/test-release-tokens.py
Original file line number Diff line number Diff line change
@@ -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()
3 changes: 2 additions & 1 deletion scripts/test-tool-integrity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading