Skip to content
Closed
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
102 changes: 74 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,38 +1,42 @@
# SemaFore - End-to-End Encrypted Notifications for GitHub Actions

Early-stage GitHub Action for sending end-to-end encrypted SemaFore
notifications from GitHub Actions workflows.
Send workflow notifications from GitHub Actions into SemaFore without handing
the message body to a notification vendor.

The goal is simple: workflow messages should be encrypted in the GitHub runner
before they leave the customer's trust boundary. Attomus infrastructure should
route encrypted envelopes, not read notification content.
SemaFore is end-to-end encrypted operational messaging for organisations. This
Action is the GitHub runner-side bridge: it prepares workflow messages where
the workflow runs, encrypts them for your SemaFore recipients, and hands
SemaFore servers encrypted envelopes to deliver.

## Status

This repository is public, but it is not Marketplace-ready yet.
This repository is public and in active development. It is not Marketplace-ready
yet.

Implemented today:

- Action metadata and Node 20 entrypoint
- input parsing and mode dispatch
- secret-origin checks for sensitive inputs
- GitHub Actions log masking for tokens and device keys
- SemaFore execute-mode HTTP client
- bootstrap secret-writing helper
- main Action plus `bootstrap/` sub-action
- input parsing and mode dispatch for `notify`, `execute`, and `bootstrap`
- defensive checks for obvious non-secret sensitive inputs
- GitHub Actions log masking for service tokens and device keys
- shorthand template rendering for workflow context values
- execute-mode HTTP client
- bootstrap GitHub secret lookup and idempotency guard
- bundled `dist/` output
- lint, type-check, test, build, and audit workflow
- lint, type-check, test, build, audit, and release workflows

Still in active development:
Blocked until upstreams are live:

- full notify-mode recipient lookup and per-device encryption
- bootstrap integration with the `@attomus/semafore-crypto` package
- end-to-end staging runs against the SemaFore integration endpoints
- Marketplace release packaging and `v1` tag movement
- notify-mode recipient encryption using `@attomus/semafore-crypto@1.0.0`
- bootstrap key generation and encrypted secret write
- staging end-to-end runs against the SemaFore integration endpoints
- Marketplace release, screenshots, and floating `v1` tag

The public API, inputs, and examples may change before the first Marketplace
release.

## Planned Notify Example
## Quick Notify Example

```yaml
name: Notify SemaFore
Expand All @@ -54,7 +58,7 @@ jobs:
template: 'Build {{run_id}} on {{ref}} completed at ${{ github.sha }}'
```

## Planned One-Time Bootstrap
## One-Time Bootstrap

```yaml
name: Bootstrap SemaFore
Expand All @@ -76,16 +80,19 @@ jobs:
github_token: ${{ secrets.GITHUB_TOKEN }}
```

Bootstrap is intended to create the repository's SemaFore device key material
and store it in GitHub Actions secrets. It refuses to overwrite
`SEMAFORE_DEVICE_KEY` if that secret already exists.
Bootstrap is designed to create this repository's SemaFore device key material
inside the GitHub Actions runner, register the public key bundle with SemaFore,
and store the private device state in GitHub Actions secrets. It refuses to
overwrite `SEMAFORE_DEVICE_KEY`; revoke the old device first, then bootstrap
again.

## Inputs

| Input | Required | Modes | Description |
|---|---:|---|---|
| `token` | yes | notify, execute | SemaFore service token stored as a GitHub Actions secret. |
| `device_key` | notify only | notify | Device private key stored as a GitHub Actions secret. |
| `token` | yes | notify, execute, bootstrap via main Action | SemaFore service token. Must come from `secrets.SEMAFORE_TOKEN`. |
| `bootstrap_token` | bootstrap sub-action only | bootstrap | Bootstrap-capability token. Must come from `secrets.SEMAFORE_BOOTSTRAP_TOKEN`. |
| `device_key` | notify only | notify | SemaFore device private state. Must come from `secrets.SEMAFORE_DEVICE_KEY`. |
| `mode` | yes | all | `notify`, `execute`, or `bootstrap`. |
| `target` | notify only | notify | `org`, `group:<id>`, or `user:<id>`. |
| `template` | notify only | notify | Message template. GitHub expressions resolve before the Action runs. |
Expand All @@ -95,24 +102,63 @@ and store it in GitHub Actions secrets. It refuses to overwrite
| `api_base_url` | optional | all | Override for staging or test servers. |
| `github_token` | bootstrap only | bootstrap | GitHub token with `actions: write` permission. |

## Outputs

| Output | Mode | Description |
|---|---|---|
| `message_id` | notify | SemaFore message identifier when returned by the server. |
| `result` | execute | JSON result returned by the execute endpoint. |
| `device_id` | bootstrap | Registered SemaFore device identifier. |

## Execute Actions

- `create_thread` posts to `/api/integrations/execute/thread/create`.
- `archive_thread` posts to `/api/integrations/execute/thread/archive/{thread_id}`.
- `audit_event` posts to `/api/integrations/execute/audit/event`.

Execute mode is for workflow events that naturally run inside GitHub Actions:
pushes, pull requests, deployments, schedules, and manual dispatches. Webhook
style events that do not run a workflow step cleanly are v2 GitHub App territory.

## Templates

GitHub resolves `${{ github.* }}` expressions before this Action starts. The
Action also supports a small shorthand set:

- `{{run_id}}`
- `{{ref}}`
- `{{sha}}`
- `{{actor}}`
- `{{workflow}}`
- `{{repository}}`

Unknown shorthand values render as an empty string.

## Security Model

- Notification content is encrypted in the GitHub runner before transport.
- SemaFore servers receive encrypted envelopes, not notification plaintext.
- Service tokens and device keys must be stored as GitHub Actions secrets.
- Sensitive input values are registered with the GitHub Actions masking API at
- Sensitive inputs are registered with the GitHub Actions masking API at
startup.
- The Action does not ask users to paste plaintext secrets into workflow files.
- The Action rejects obvious placeholder or literal-looking sensitive inputs.

GitHub's JavaScript Action runtime does not expose a reliable "this input came
from `secrets.*`" marker to the Action process. The runtime check is therefore a
defensive guardrail, not a proof. The real requirement is operational: put
`SEMAFORE_TOKEN`, `SEMAFORE_DEVICE_KEY`, and `SEMAFORE_BOOTSTRAP_TOKEN` in GitHub
Actions secrets and reference them from `secrets.*`.

## Pricing

The Action is intended to be free on GitHub Marketplace. It requires a SemaFore
organisation account; SemaFore plan limits and billing live in SemaFore, not in
Marketplace tiers.

## Versioning

Tagged `v1.x.x` releases will become Marketplace versions once the Action is
ready. The floating `v1` tag will point to the latest compatible v1 release.
Tagged `v1.x.x` releases become Marketplace versions once the Action is ready.
The floating `v1` tag points to the latest compatible v1 release.

## Responsible Disclosure

Expand Down
7 changes: 7 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ inputs:
github_token:
required: false
description: 'GitHub token with actions: write permission for bootstrap mode.'
outputs:
message_id:
description: 'SemaFore message identifier returned by notify mode when available.'
result:
description: 'JSON result returned by execute mode.'
device_id:
description: 'Registered SemaFore device identifier returned by bootstrap mode.'
runs:
using: 'node20'
main: 'dist/index.js'
3 changes: 3 additions & 0 deletions bootstrap/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ inputs:
github_token:
required: false
description: 'GitHub token with actions: write permission. Defaults to GITHUB_TOKEN.'
outputs:
device_id:
description: 'Registered SemaFore device identifier.'
runs:
using: 'node20'
main: '../dist/index.js'
38 changes: 32 additions & 6 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -31320,13 +31320,13 @@ function parseInputs(raw, logger = coreLogger) {
const mode = normalizeMode(raw.mode);
const apiBaseUrl = normalizeApiBaseUrl(raw.apiBaseUrl);
const token = mode === 'bootstrap' ? required(raw.bootstrapToken ?? raw.token, 'bootstrap_token') : required(raw.token, 'token');
protectSensitiveInput(token, 'SEMAFORE_TOKEN', logger);
protectSensitiveInput(token, 'SEMAFORE_TOKEN', logger, raw.allowUnsafeSecretInputs === true);
if (mode === 'bootstrap') {
return { mode, token, apiBaseUrl };
}
if (mode === 'notify') {
const deviceKey = required(raw.deviceKey, 'device_key');
protectSensitiveInput(deviceKey, 'SEMAFORE_DEVICE_KEY', logger);
protectSensitiveInput(deviceKey, 'SEMAFORE_DEVICE_KEY', logger, raw.allowUnsafeSecretInputs === true);
const notify = {
mode,
token,
Expand Down Expand Up @@ -31403,8 +31403,11 @@ function optionalNonEmpty(value) {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function protectSensitiveInput(value, label, logger) {
function protectSensitiveInput(value, label, logger, allowUnsafeSecretInputs) {
logger.setSecret(value);
if (allowUnsafeSecretInputs) {
return;
}
if (looksLikeUnsafeLiteral(value)) {
throw new Error('SEMAFORE_TOKEN and SEMAFORE_DEVICE_KEY must be stored as GitHub Actions secrets, not literals or variables.');
}
Expand All @@ -31414,12 +31417,17 @@ function protectSensitiveInput(value, label, logger) {
}
function looksLikeUnsafeLiteral(value) {
const normalized = value.toLowerCase();
return (normalized.includes('replace-me') ||
return (normalized.startsWith('${{') ||
normalized.includes('replace-me') ||
normalized.includes('changeme') ||
normalized.includes('example') ||
normalized.includes('not-a-secret') ||
normalized.includes('paste-token-here') ||
normalized.includes('literal') ||
normalized === 'token' ||
normalized === 'device_key');
normalized === 'device_key' ||
normalized === 'semafore_token' ||
normalized === 'semafore_device_key');
}
function inferModeFromActionPath() {
const actionPath = process.env.GITHUB_ACTION_PATH ?? '';
Expand Down Expand Up @@ -31491,7 +31499,7 @@ async function runNotify(inputs, client, logger) {
logger.setSecret(inputs.token);
logger.setSecret(inputs.deviceKey);
const body = renderTemplate(inputs.template, githubContextFromEnv());
throw new Error(`Notify is scaffolded but blocked until @attomus/semafore-crypto is published and integration recipient endpoints are live. Rendered body length: ${body.length}. Client ready: ${Boolean(client)}.`);
throw new Error(`Notify is scaffolded but blocked until @attomus/semafore-crypto is published and encrypted envelopes can be produced. Rendered body length: ${body.length}. Client ready: ${Boolean(client)}.`);
}

;// CONCATENATED MODULE: ./src/secrets.ts
Expand Down Expand Up @@ -31532,8 +31540,26 @@ class GitHubRepoSecretWriter {
if (!name.trim() || !value.trim()) {
throw new Error('secret name and value are required');
}
await this.fetchPublicKey();
throw new Error('GitHub secret writing is blocked until bootstrap depends on the published crypto package.');
}
async fetchPublicKey() {
const response = await fetch(`https://api.github.com/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(this.repo)}/actions/secrets/public-key`, {
headers: {
accept: 'application/vnd.github+json',
authorization: `Bearer ${this.githubToken}`,
'x-github-api-version': '2022-11-28'
}
});
if (!response.ok) {
throw new Error(`GitHub public key lookup failed with status ${response.status}.`);
}
const body = (await response.json());
if (!body.key_id || !body.key) {
throw new Error('GitHub public key response was missing key_id or key.');
}
return { key_id: body.key_id, key: body.key };
}
}

;// CONCATENATED MODULE: ./src/main.ts
Expand Down
2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ export interface ExecuteRequest {

export interface NotifyRequest {
readonly target: unknown;
readonly bodyPreviewLength: number;
readonly severity?: string;
readonly metadata: {
readonly bodyLength: number;
};
readonly envelopes: unknown[];
}

Expand Down
17 changes: 13 additions & 4 deletions src/inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export interface RawInputs {
readonly params?: string | undefined;
readonly severity?: string | undefined;
readonly apiBaseUrl?: string | undefined;
readonly allowUnsafeSecretInputs?: boolean | undefined;
}

export function readInputs(logger: Logger = coreLogger): ActionInputs {
Expand All @@ -78,15 +79,15 @@ export function parseInputs(raw: RawInputs, logger: Logger = coreLogger): Action
const mode = normalizeMode(raw.mode);
const apiBaseUrl = normalizeApiBaseUrl(raw.apiBaseUrl);
const token = mode === 'bootstrap' ? required(raw.bootstrapToken ?? raw.token, 'bootstrap_token') : required(raw.token, 'token');
protectSensitiveInput(token, 'SEMAFORE_TOKEN', logger);
protectSensitiveInput(token, 'SEMAFORE_TOKEN', logger, raw.allowUnsafeSecretInputs === true);

if (mode === 'bootstrap') {
return { mode, token, apiBaseUrl };
}

if (mode === 'notify') {
const deviceKey = required(raw.deviceKey, 'device_key');
protectSensitiveInput(deviceKey, 'SEMAFORE_DEVICE_KEY', logger);
protectSensitiveInput(deviceKey, 'SEMAFORE_DEVICE_KEY', logger, raw.allowUnsafeSecretInputs === true);
const notify: NotifyInputs = {
mode,
token,
Expand Down Expand Up @@ -172,8 +173,11 @@ function optionalNonEmpty(value?: string): string | undefined {
return trimmed ? trimmed : undefined;
}

function protectSensitiveInput(value: string, label: string, logger: Logger): void {
function protectSensitiveInput(value: string, label: string, logger: Logger, allowUnsafeSecretInputs: boolean): void {
logger.setSecret(value);
if (allowUnsafeSecretInputs) {
return;
}
if (looksLikeUnsafeLiteral(value)) {
throw new Error(
'SEMAFORE_TOKEN and SEMAFORE_DEVICE_KEY must be stored as GitHub Actions secrets, not literals or variables.'
Expand All @@ -187,12 +191,17 @@ function protectSensitiveInput(value: string, label: string, logger: Logger): vo
function looksLikeUnsafeLiteral(value: string): boolean {
const normalized = value.toLowerCase();
return (
normalized.startsWith('${{') ||
normalized.includes('replace-me') ||
normalized.includes('changeme') ||
normalized.includes('example') ||
normalized.includes('not-a-secret') ||
normalized.includes('paste-token-here') ||
normalized.includes('literal') ||
normalized === 'token' ||
normalized === 'device_key'
normalized === 'device_key' ||
normalized === 'semafore_token' ||
normalized === 'semafore_device_key'
);
}

Expand Down
2 changes: 1 addition & 1 deletion src/notify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@ export async function runNotify(inputs: NotifyInputs, client: SemaForeClient, lo
const body = renderTemplate(inputs.template, githubContextFromEnv());

throw new Error(
`Notify is scaffolded but blocked until @attomus/semafore-crypto is published and integration recipient endpoints are live. Rendered body length: ${body.length}. Client ready: ${Boolean(client)}.`
`Notify is scaffolded but blocked until @attomus/semafore-crypto is published and encrypted envelopes can be produced. Rendered body length: ${body.length}. Client ready: ${Boolean(client)}.`
);
}
31 changes: 31 additions & 0 deletions src/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ export interface RepoSecretWriter {
writeSecret(name: string, value: string): Promise<void>;
}

interface PublicKeyResponse {
readonly key_id: string;
readonly key: string;
}

export class GitHubRepoSecretWriter implements RepoSecretWriter {
private readonly owner: string;
private readonly repo: string;
Expand Down Expand Up @@ -54,8 +59,34 @@ export class GitHubRepoSecretWriter implements RepoSecretWriter {
if (!name.trim() || !value.trim()) {
throw new Error('secret name and value are required');
}
await this.fetchPublicKey();
throw new Error(
'GitHub secret writing is blocked until bootstrap depends on the published crypto package.'
);
}

async fetchPublicKey(): Promise<PublicKeyResponse> {
const response = await fetch(
`https://api.github.com/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(
this.repo
)}/actions/secrets/public-key`,
{
headers: {
accept: 'application/vnd.github+json',
authorization: `Bearer ${this.githubToken}`,
'x-github-api-version': '2022-11-28'
}
}
);

if (!response.ok) {
throw new Error(`GitHub public key lookup failed with status ${response.status}.`);
}

const body = (await response.json()) as Partial<PublicKeyResponse>;
if (!body.key_id || !body.key) {
throw new Error('GitHub public key response was missing key_id or key.');
}
return { key_id: body.key_id, key: body.key };
}
}
Loading