Skip to content
Open
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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ jobs:
id: npm-ci-test
run: nix develop --impure --command bash -c "pnpm ci-test"

test-attribution-launcher:
name: Attribution launcher tests
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- run: python3 -m unittest discover -s scripts -v

test-action:
name: GitHub Actions Test
runs-on: ubuntu-24.04
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,6 @@ __tests__/runner/*
# Nix
.direnv
.pre-commit-config.yaml

# Python attribution tests
__pycache__/
65 changes: 57 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,9 +153,12 @@ is literally `runner`, and a workflow author can set it to anything.

Set `attribution_url` and the action will instead ask GitHub for a token
asserting this job's identity, exchange it with NativeLink for an opaque build
ticket, and add that ticket to the `.bazelrc` it writes. NativeLink then shows
the GitHub user, repository, branch, commit and pull request on the build —
verified against GitHub's signature rather than taken on trust.
ticket immediately before each Bazel invocation. The Action installs a `bazel`
launcher on `PATH`; existing `bazel build`, `bazel test`, and `bazel run`
commands keep working. Each invocation gets a fresh ticket, including concurrent
builds. Tickets are never written into the shared `.bazelrc`. NativeLink then
shows the GitHub user, repository, branch, commit and pull request on the build
— verified against GitHub's signature rather than taken on trust.

```yaml
permissions:
Expand All @@ -172,11 +175,17 @@ steps:
attribution_url: https://github-app.nativelink.com
```

**Needs v0.0.3 or newer.** The attribution inputs arrived in that release, so
`@v0` is correct only if it has been moved to it — which it has. Referencing an
older version does not fail: an unknown input is ignored, so the action runs and
configures a perfectly ordinary unattributed build. If attribution silently does
not appear, check the version you resolved before looking anywhere else.
**Per-invocation handling requires the release containing this change.**
Versions through v0.0.4 obtain one ticket during setup and therefore cannot
attribute multiple invocations in the same job. Until the next release is
published, pin the reviewed commit containing this fix.

**The attribution inputs need v0.0.3 or newer.** The attribution inputs arrived
in that release, so `@v0` is correct only if it has been moved to it — which it
has. Referencing an older version does not fail: an unknown input is ignored, so
the action runs and configures a perfectly ordinary unattributed build. If
attribution silently does not appear, check the version you resolved before
looking anywhere else.

| Input | Default | Purpose |
| --------------------------- | ---------------- | ----------------------------------------------------------------------- |
Expand All @@ -200,3 +209,43 @@ Three things worth knowing:
Bazel only for now — `--bes_keywords` is a Bazel concept and buck2 has no
equivalent channel to carry the ticket, so the input is ignored there with a
warning.

### Existing Bazel configuration

If another setup Action already writes your cache/BES configuration, use the
attribution-only entry point after it, with the same reviewed release/commit:

```yaml
- uses: TraceMachina/nativelink-action/attribution@<reviewed-commit>
with:
attribution_url: https://github-app.nativelink.com
bazelrc: user.bazelrc
```

This keeps the existing endpoints, credentials, and cache-write restrictions. An
RC without a BES backend, such as a cache-only fork configuration, installs no
attribution launcher. Individual PR authors do not need to link a profile.
Python 3 and Bazel/Bazelisk must be installed on the runner. Run Bazel setup
before this Action so a later setup step does not replace its `PATH` entry.

For a Nix shell or an absolute Bazelisk invocation that bypasses `PATH`, a
workspace `tools/bazel` hook can run
`NATIVELINK_BAZEL_REAL="$BAZEL_REAL" python3 "$NATIVELINK_BAZEL_WRAPPER" "$@"`
when that variable is set, otherwise `exec "$BAZEL_REAL" "$@"`. Bazelisk
supplies `BAZEL_REAL`; nested launcher/hook calls request only one ticket.
Absolute calls to a native Bazel binary bypass both hooks and must invoke the
wrapper explicitly.

For BuildKit, the attribution-only Action exposes `attribution-config` and
`attribution-wrapper` file outputs. Mount both as build secrets, set
`NATIVELINK_ATTRIBUTION_CONFIG` and `NATIVELINK_BAZEL_WRAPPER` to their mount
paths, and set `NATIVELINK_ATTRIBUTION_BAZELRC` to the container's RC path. The
configuration contains a job-scoped GitHub OIDC request credential: never put it
in build arguments, image layers, logs, caches, or uploaded artifacts. The
Action removes its temporary directory in its post step.

To verify the integration, run two Bazel builds in one enabled repository/job,
then check that both NativeLink build pages show the repository and GitHub
identity. Repeat for a PR and its merge; the PR author should be shown
separately from the workflow actor. Local regression commands are `pnpm ci-test`
and `python3 -m unittest discover -s scripts -v`.
137 changes: 137 additions & 0 deletions __tests__/attribution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { jest } from '@jest/globals'
import { fs, vol } from 'memfs'
import { fileURLToPath } from 'node:url'

jest.unstable_mockModule('node:fs', () => ({ default: fs }))
jest.unstable_mockModule('node:child_process', () => ({
execFileSync: jest.fn(() => '/usr/bin/python3\n')
}))
const { installAttribution } = await import('../src/attribution.js')

const makeCore = (inputs: Record<string, string> = {}) => ({
getInput: (key: string) =>
({ attribution_url: 'https://github-app.example', ...inputs })[key] || '',
warning: jest.fn(),
info: jest.fn(),
setSecret: jest.fn(),
addPath: jest.fn(),
exportVariable: jest.fn(),
setOutput: jest.fn(),
saveState: jest.fn()
})

describe('attribution installation', () => {
const originalEnv = { ...process.env }
beforeEach(() => {
process.env = {
...originalEnv,
RUNNER_TEMP: '/runner',
ACTIONS_ID_TOKEN_REQUEST_URL: 'https://oidc.example/token',
ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'private-request-token'
}
vol.reset()
vol.mkdirSync('/runner', { recursive: true })
vol.mkdirSync(process.cwd(), { recursive: true })
const script = fileURLToPath(
new URL('../scripts/bazel.py', import.meta.url)
)
vol.mkdirSync(fileURLToPath(new URL('../scripts/', import.meta.url)), {
recursive: true
})
vol.writeFileSync(script, '# portable invocation wrapper')
vol.writeFileSync(
'user.bazelrc',
'build --bes_backend=grpcs://bes.example\n'
)
})
afterEach(() => {
process.env = originalEnv
})

it('exports a portable wrapper and private config without putting a ticket in the bazelrc', () => {
const core = makeCore({ attribution_audience: 'enterprise.example' })
installAttribution(core, 'user.bazelrc')
expect(core.warning).not.toHaveBeenCalled()
const directory = core.saveState.mock.calls[0][1] as string
const config = JSON.parse(
vol.readFileSync(`${directory}/config.json`, 'utf8') as string
)
expect(config).toMatchObject({
url: 'https://github-app.example',
audience: 'enterprise.example',
strict: false,
oidcToken: 'private-request-token'
})
expect(fs.statSync(`${directory}/config.json`).mode & 0o777).toBe(0o600)
expect(fs.statSync(directory).mode & 0o777).toBe(0o700)
expect(core.addPath).toHaveBeenCalledWith(`${directory}/bin`)
expect(core.setSecret).toHaveBeenCalledWith('private-request-token')
expect(core.setOutput).toHaveBeenCalledWith(
'attribution-wrapper',
`${directory}/bazel.py`
)
expect(vol.readFileSync('user.bazelrc', 'utf8')).not.toContain('nl_ticket:')
expect(JSON.stringify(core.info.mock.calls)).not.toContain(
'private-request-token'
)
})

it('does nothing when attribution is not configured', () => {
const core = makeCore({ attribution_url: '' })
installAttribution(core, 'user.bazelrc')
expect(core.addPath).not.toHaveBeenCalled()
expect(core.saveState).not.toHaveBeenCalled()
})

it('does not create credentials or a wrapper for a cache-only fork', () => {
vol.writeFileSync(
'user.bazelrc',
'build --remote_cache=grpcs://cas.example\n'
)
const core = makeCore()
installAttribution(core, 'user.bazelrc')
expect(core.addPath).not.toHaveBeenCalled()
expect(core.saveState).not.toHaveBeenCalled()
expect(core.warning).not.toHaveBeenCalled()
})

it('explains a missing job permission without exposing credentials', () => {
delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL
const core = makeCore()
installAttribution(core, 'user.bazelrc')
expect(core.warning).toHaveBeenCalledWith(
expect.stringContaining('id-token: write')
)
expect(JSON.stringify(core.warning.mock.calls)).not.toContain(
'private-request-token'
)
expect(core.addPath).not.toHaveBeenCalled()
})

it.each([
'http://app.example',
'https://user:[email protected]',
'https://app.example/path',
'https://app.example?token=secret',
'https://app.example#fragment'
])('refuses an unsafe service URL: %s', (url) => {
const core = makeCore({ attribution_url: url })
installAttribution(core, 'user.bazelrc')
expect(core.warning).toHaveBeenCalled()
expect(core.saveState).not.toHaveBeenCalled()
expect(JSON.stringify(core.warning.mock.calls)).not.toContain('secret')
})

it.each(['true', 'TRUE', 'yes', '1', 'on'])(
'honors strict mode %s for setup failures',
(strict) => {
delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL
expect(() =>
installAttribution(
makeCore({ fail_on_attribution_error: strict }),
'user.bazelrc'
)
).toThrow('NativeLink build attribution failed')
}
)
})
Loading
Loading