Skip to content

Commit f0bffc6

Browse files
committed
feat(publish): cross-org CLI-tail publish pipeline w/ source-allowlist trust gate
Mirrors socket-addon's cross-org publish wiring; symmetric infrastructure for @socketbin/* CLI tails: - `scripts/source-allowlist.mts` — typed export of `SOURCE_ALLOWLIST`, currently empty (EMPTY_ALLOWLIST sentinel). The first binsuite family adds its row here BEFORE merging the package directory — the publisher refuses publish attempts for unknown families, so the allowlist row is the trust grant that turns the package directory on. - `scripts/publish-cross-org.mts` — entry-point that mirrors socket-addon's byte-for-byte except the kind: 'cli' default flows through buildBinaryPathInTail() to place `bin/<name>[.exe]` instead of `<name>.node`. - `.github/workflows/publish-cross-org.yml` — workflow_dispatch trigger. Default dry-run: true; real publishes require the canonical bypass. - `package.json` — `publish:cross-org` script. - `CHANGELOG.md` — bootstrap with the Unreleased entry. The publisher will refuse every publish until the first SOURCE_ALLOWLIST row lands — that's the intended state for a freshly-scaffolded publisher with no authorized sources yet.
1 parent 6d4276d commit f0bffc6

5 files changed

Lines changed: 274 additions & 0 deletions

File tree

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
name: 🚀 Cross-org publish
2+
3+
# Bespoke socket-bin publish pipeline for cross-org CLI tails. Reads the
4+
# repo's source-allowlist.mts to authorize a (source-repo, release-tag) pair,
5+
# downloads the release artifacts via `gh release download`, verifies their
6+
# attestations against the allowlist row's signer-workflow, extracts each
7+
# triplet's archive into its `packages/<prefix><triplet>/` directory, stamps
8+
# the version, and (unless dry-run) `npm publish --provenance` per tail.
9+
#
10+
# Trust layers (every successful run requires ALL):
11+
# 1. Allowlist match — entry in scripts/source-allowlist.mts.
12+
# 2. Tag conformance — release tag matches the row's tagPattern.
13+
# 3. SHA256SUMS attested — `gh attestation verify` succeeds.
14+
# 4. Per-archive attested — same, for each downloaded tarball.
15+
# 5. Tarball SHA matches SHA256SUMS — local recomputation.
16+
# 6. Manifest name conformance — extracted package.json's `name` is exactly
17+
# `<targetScope>/<namePrefix><triplet>`.
18+
#
19+
# Bypass: this workflow accepts a `dry-run` input to satisfy
20+
# release-workflow-guard. The default is true. A real publish requires
21+
# `Allow workflow-dispatch bypass: publish-cross-org` typed verbatim in the
22+
# session that runs `gh workflow run … -f dry-run=false`.
23+
24+
on:
25+
workflow_dispatch:
26+
inputs:
27+
source-repo:
28+
description: 'GitHub <owner>/<repo> hosting the release artifacts (must match an allowlist row)'
29+
required: true
30+
type: string
31+
release-tag:
32+
description: 'Release tag in source-repo (must match the row''s tagPattern)'
33+
required: true
34+
type: string
35+
dry-run:
36+
description: 'Stage + verify only; skip npm publish (default: true)'
37+
required: false
38+
default: true
39+
type: boolean
40+
41+
permissions:
42+
contents: read
43+
id-token: write
44+
attestations: read
45+
46+
jobs:
47+
cross-org-publish:
48+
name: Stage + (optionally) publish
49+
runs-on: ubuntu-latest
50+
timeout-minutes: 30
51+
52+
steps:
53+
- name: Checkout
54+
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 (2025-08-15)
55+
56+
- name: Setup + install
57+
uses: SocketDev/socket-registry/.github/actions/setup-and-install@f09a1cd39868ae45d304cfcead2d4f19a5325d8a # main (2026-06-01)
58+
59+
- name: Stage + publish
60+
env:
61+
SOURCE_REPO: ${{ inputs.source-repo }}
62+
RELEASE_TAG: ${{ inputs.release-tag }}
63+
DRY_RUN: ${{ inputs.dry-run }}
64+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
65+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
66+
run: node scripts/publish-cross-org.mts
67+
68+
- name: Summarize publish
69+
if: always()
70+
run: |
71+
{
72+
echo "## socket-bin cross-org publish"
73+
echo ""
74+
echo "| Input | Value |"
75+
echo "| --- | --- |"
76+
echo "| source-repo | \`${{ inputs.source-repo }}\` |"
77+
echo "| release-tag | \`${{ inputs.release-tag }}\` |"
78+
echo "| dry-run | \`${{ inputs.dry-run }}\` |"
79+
echo "| result | ${{ job.status }} |"
80+
} >> "$GITHUB_STEP_SUMMARY"

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Changelog
2+
3+
All notable changes to this package will be documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7+
8+
## [Unreleased]
9+
10+
### Added
11+
12+
- `scripts/source-allowlist.mts`: authoritative allowlist of `(source-repo,
13+
build-workflow, tag-pattern)` tuples authorized to mint `@socketbin/*`
14+
CLI-tail packages. Empty until the first binsuite family is scaffolded.
15+
- `scripts/publish-cross-org.mts`: entry-point that reads the allowlist,
16+
delegates the download → verify → extract → stage pipeline to the fleet
17+
`stageMultiPackagePublish()` runner, and `npm publish --provenance` per
18+
staged tail.
19+
- `.github/workflows/publish-cross-org.yml`: `workflow_dispatch` trigger
20+
(`source-repo`, `release-tag`, `dry-run` inputs) that runs the publisher
21+
under the workflow's OIDC identity. Default `dry-run: true`; a real
22+
publish requires the canonical `Allow workflow-dispatch bypass:
23+
publish-cross-org` phrase in the operator's session.
24+
- `package.json`: `publish:cross-org` script.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"prepare": "node scripts/fleet/install-git-hooks.mts && node scripts/fleet/install-git-hooks.mts",
2929
"publish": "node scripts/fleet/publish.mts",
3030
"publish:ci": "node scripts/fleet/publish.mts --tag ${DIST_TAG:-latest}",
31+
"publish:cross-org": "node scripts/publish-cross-org.mts",
3132
"publish:dry": "node scripts/fleet/publish.mts --dry-run",
3233
"security": "node scripts/fleet/security.mts",
3334
"setup": "node .claude/hooks/fleet/setup-security-tools/index.mts",

scripts/publish-cross-org.mts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/**
2+
* @file Entry point for socket-bin's cross-org tail publishes. Driven by the
3+
* `publish-cross-org.yml` workflow's `workflow_dispatch` inputs (mapped
4+
* into env vars: `SOURCE_REPO`, `RELEASE_TAG`, `DRY_RUN`). Reads
5+
* `scripts/source-allowlist.mts` for the trust boundary, delegates the
6+
* download → verify → extract → stage pipeline to the fleet
7+
* `stageMultiPackagePublish` runner, and on non-dry-run iterates the
8+
* staged tails and `npm publish --provenance` each one.
9+
*
10+
* Mirrors `socket-addon/scripts/publish-cross-org.mts` byte-for-byte
11+
* except for the tail-directory convention (`packages/<prefix><triplet>`
12+
* is identical in both repos) and the `kind` field on allowlist rows
13+
* (socket-addon families are NAPI; socket-bin families are CLI). The
14+
* `kind` distinction flows through `buildBinaryPathInTail()`, which
15+
* places `bin/<name>[.exe]` instead of `<name>.node`.
16+
*
17+
* The allowlist is empty until the first binsuite family is scaffolded
18+
* — the runner refuses on `allowlist-miss`, which is the intended state
19+
* for a freshly-scaffolded publisher with no authorized sources yet.
20+
*
21+
* Exit codes:
22+
* 0 — every requested tail staged + (unless dry-run) published.
23+
* 1 — any stage or publish failure (the first one; fail-fast).
24+
*/
25+
26+
import path from 'node:path'
27+
import { fileURLToPath } from 'node:url'
28+
29+
import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default'
30+
31+
import { runCommand } from './fleet/util/run-command.mts'
32+
import {
33+
MultiPackageStageError,
34+
stageMultiPackagePublish,
35+
type TailStageOutcome,
36+
} from './fleet/util/multi-package-publish.mts'
37+
import type { GitHubRepoSlug } from './fleet/util/source-allowlist.mts'
38+
import {
39+
buildBinaryPathInTail,
40+
findAllowlistEntry,
41+
} from './fleet/util/source-allowlist.mts'
42+
import { SOURCE_ALLOWLIST } from './source-allowlist.mts'
43+
44+
const logger = getDefaultLogger()
45+
const REPO_ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
46+
const STAGING_DIR = path.join(REPO_ROOT, '.cross-org-stage')
47+
48+
function readRequiredEnv(name: string): string {
49+
const value = process.env[name]
50+
if (!value || value.trim() === '') {
51+
throw new Error(
52+
`Missing required env: ${name}. Set it via workflow_dispatch input or the calling shell.`,
53+
)
54+
}
55+
return value.trim()
56+
}
57+
58+
function isGitHubRepoSlug(value: string): value is GitHubRepoSlug {
59+
const parts = value.split('/')
60+
return parts.length === 2 && parts[0]!.length > 0 && parts[1]!.length > 0
61+
}
62+
63+
async function publishTail(tail: TailStageOutcome): Promise<void> {
64+
logger.log(`Publishing ${tail.tailName}@${tail.version}…`)
65+
const exitCode = await runCommand(
66+
'npm',
67+
['publish', '--access', 'public', '--provenance'],
68+
{ cwd: tail.tailDir },
69+
)
70+
if (exitCode !== 0) {
71+
throw new Error(
72+
`npm publish failed for ${tail.tailName}@${tail.version} (exit ${exitCode}). See above stderr from npm.`,
73+
)
74+
}
75+
logger.success(`Published ${tail.tailName}@${tail.version}`)
76+
}
77+
78+
async function main(): Promise<void> {
79+
const sourceRepoRaw = readRequiredEnv('SOURCE_REPO')
80+
if (!isGitHubRepoSlug(sourceRepoRaw)) {
81+
throw new Error(
82+
`SOURCE_REPO must be <owner>/<repo>; got ${sourceRepoRaw}.`,
83+
)
84+
}
85+
const sourceRepo: GitHubRepoSlug = sourceRepoRaw
86+
const releaseTag = readRequiredEnv('RELEASE_TAG')
87+
const dryRun = process.env['DRY_RUN'] !== 'false'
88+
89+
const entry = findAllowlistEntry(SOURCE_ALLOWLIST, sourceRepo, releaseTag)
90+
if (!entry) {
91+
throw new MultiPackageStageError(
92+
`No socket-bin allowlist row matches ${sourceRepo} tag ${releaseTag}. Add a SourceAllowlistEntry in scripts/source-allowlist.mts or correct the inputs.`,
93+
'allowlist-miss',
94+
)
95+
}
96+
97+
logger.log(`socket-bin cross-org publish`)
98+
logger.log(` source: ${sourceRepo} @ ${releaseTag}`)
99+
logger.log(` family: ${entry.familyId} (${entry.kind}${entry.binaryName})`)
100+
logger.log(` dry-run: ${dryRun}`)
101+
102+
const result = await stageMultiPackagePublish({
103+
allowlist: SOURCE_ALLOWLIST,
104+
sourceRepo,
105+
releaseTag,
106+
tailDirFor: triplet =>
107+
path.join(REPO_ROOT, 'packages', `${entry.namePrefix}${triplet}`),
108+
binaryPathInTail: triplet => buildBinaryPathInTail(entry, triplet),
109+
stagingDir: STAGING_DIR,
110+
dryRun,
111+
})
112+
113+
logger.log(
114+
`Staged ${result.tails.length} tail(s) for ${result.entry.familyId}@${result.version}`,
115+
)
116+
117+
if (dryRun) {
118+
logger.log('Dry run — skipping npm publish step. Done.')
119+
return
120+
}
121+
122+
for (let i = 0, { length } = result.tails; i < length; i += 1) {
123+
// eslint-disable-next-line no-await-in-loop
124+
await publishTail(result.tails[i]!)
125+
}
126+
127+
logger.success(
128+
`socket-bin cross-org publish complete: ${result.tails.length} tail(s) → ${entry.targetScope}/${entry.namePrefix}*@${result.version}`,
129+
)
130+
}
131+
132+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
133+
main().catch((err: unknown) => {
134+
logger.error(err instanceof Error ? err.message : String(err))
135+
process.exitCode = 1
136+
})
137+
}
138+
139+
export { main }

scripts/source-allowlist.mts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* @file socket-bin's source-allowlist — the trust boundary for every
3+
* `@socketbin/*` CLI-tail this repo publishes. Each entry is one
4+
* (source-repo, build-workflow, tag-pattern) tuple authorized to mint a
5+
* family of npm tails. Adding a row is a fleet-level review (new trust
6+
* grant); removing a row immediately revokes that family's ability to
7+
* publish through socket-bin.
8+
*
9+
* The publisher (`scripts/publish-cross-org.mts`) reads this array and
10+
* refuses any publish whose `(sourceRepo, releaseTag)` doesn't match a
11+
* row. Allowlist match is layer 1 of the trust gate; artifact attestation
12+
* verification via `gh attestation verify` is layer 2. Both must pass.
13+
*
14+
* Currently empty: the binsuite tail packages this repo will publish are
15+
* still being scaffolded (tracked separately). When the first family
16+
* lands, add its row here BEFORE merging the package directory — the
17+
* publisher refuses publish attempts for unknown families, so the
18+
* allowlist row is the trust grant that turns the package directory on.
19+
*
20+
* Schema lives in the fleet (`scripts/fleet/util/source-allowlist.mts`);
21+
* this file ships only the data + a typed export.
22+
*/
23+
24+
import {
25+
EMPTY_ALLOWLIST,
26+
type SourceAllowlistEntry,
27+
} from './fleet/util/source-allowlist.mts'
28+
29+
export const SOURCE_ALLOWLIST: readonly SourceAllowlistEntry[] =
30+
EMPTY_ALLOWLIST

0 commit comments

Comments
 (0)