Skip to content
Merged
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
53 changes: 53 additions & 0 deletions .github/workflows/publish-cargo.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: Publish Rust crate
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: package-publication-${{ github.repository }}
cancel-in-progress: false
jobs:
publish:
if: vars.REGISTRY_PUBLISH_ENABLED == 'true' && github.event.repository.private == false
runs-on: ubuntu-latest
timeout-minutes: 30
environment: crates-io-public
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0
persist-credentials: false
- name: Require the reviewed source revision
env:
APPROVED_SHA: ${{ vars.REGISTRY_SECURITY_APPROVED_SHA }}
run: |
test "$APPROVED_SHA" = "$GITHUB_SHA"
git merge-base --is-ancestor HEAD origin/main
- name: Check registry metadata and release tag
env:
RELEASE_TAG_PREFIX: ${{ vars.REGISTRY_CARGO_TAG_PREFIX || 'v' }}
run: |
python3 scripts/check-public-package.py cargo
VERSION=$(python3 -c 'import tomllib; print(tomllib.load(open("Cargo.toml","rb"))["package"]["version"])')
test "$GITHUB_REF_TYPE" = tag
test "$GITHUB_REF_NAME" = "${RELEASE_TAG_PREFIX}${VERSION}"
TOOLCHAIN=$(python3 -c 'import tomllib; print(tomllib.load(open("Cargo.toml","rb"))["package"]["rust-version"])')
rustup toolchain install "$TOOLCHAIN" --profile minimal
rustup override set "$TOOLCHAIN"
- name: Test and verify the package against crates.io
run: |
cargo test --locked --all-features
cargo package --locked
PACKAGE=$(python3 -c 'import tomllib; p=tomllib.load(open("Cargo.toml","rb"))["package"]; print(p["name"]+"-"+p["version"]+".crate")')
python3 scripts/check-public-package.py cargo --archive "target/package/$PACKAGE"
cargo publish --dry-run --locked --registry crates-io
- name: Obtain the scoped short-lived crates.io token
id: auth
uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5
- name: Publish the verified source through OIDC
env:
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
run: cargo publish --locked --registry crates-io
3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ rust-version = "1.97"
description = "Policy-neutral GitHub API wrapper boundary for Zixcel"
license = "Apache-2.0"
readme = "README.md"
publish = ["zixcel-private"]
publish = ["crates-io"]
repository = "https://github.com/zixcel/zixcel-github"

[package.metadata.zixcel]
boundary = "github-api-wrapper"
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ cargo run --offline -- check-request examples/push-request.json

Only opaque `connection_ref` values are accepted, never credentials. OAuth, secret resolution and HTTP/Git transport belong to Crowsi/Zixcel worker boundaries. `execute_api_request` passes validated closed requests to an injected `GitHubBackend`; higher adapters authorize the caller. No path dependencies on other local repositories are used.

`parse_config` validates closed TOML up to 1 MiB; `build_plan` is independent of repository ordering. No HTTP or secret resolver is linked; execution is restricted to an injected backend trait. Cargo publication remains restricted to `zixcel-private`; these changes have not been published to that registry.
`parse_config` validates closed TOML up to 1 MiB; `build_plan` is independent of repository ordering. No HTTP or secret resolver is linked; execution is restricted to an injected backend trait. The source manifest now targets crates.io. The public publication workflow is prepared locally and remains disabled pending name ownership, trusted-publisher setup, and exact package review. Existing private-registry artifacts have not been replaced or activated.

## Responsibilities

Expand Down Expand Up @@ -66,3 +66,7 @@ Apache-2.0; see LICENSE and NOTICE. Previously granted permissions and third-par
The package is an independently consumable unit. Callers reference its documented
interface through a versioned dependency and own application-specific composition
and integration.

## Package publication templates

`templates/package-publication/` contains standalone npm and crates.io GitHub Actions templates, a public-package and archive gate, and activation policy. They are copied into each consuming repository and do not add a runtime dependency on this crate. Publishing is disabled until the registry identity, protected environment, reviewed source SHA, and package delivery prerequisites are configured. See the template policy and installation instructions in that directory.
11 changes: 11 additions & 0 deletions registry-publication.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"schema": "zixcel://github/package-publication-installation/v1",
"registry": "https://crates.io",
"workflow": "publish-cargo.yml",
"environment": "crates-io-public",
"template_repository": "zixcel/zixcel-github",
"installed_locally": true,
"trusted_publisher_configured": false,
"activation_status": "disabled-until-registry-identity-and-reviewed-SHA-configured",
"network_published": false
}
149 changes: 149 additions & 0 deletions scripts/check-public-package.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""Check public manifests and the exact archive selected for publication."""
import argparse
import fnmatch
import json
import re
import tarfile
import tomllib
from pathlib import Path, PurePosixPath


def check_manifest(root, kind):
root = Path(root).resolve()
failures = []
policy_path = root / 'public-package-policy.json'
if policy_path.is_file():
policy = json.loads(policy_path.read_text())
expected = sorted(policy['repository_ids'])
for catalog_path in [root / 'catalog-v2.json', root / 'public/catalog/v2/index.json']:
if catalog_path.is_file():
catalog = json.loads(catalog_path.read_text())
if sorted(e['repository_id'] for e in catalog['entries']) != expected:
failures.append('catalog contains entries outside the reviewed public allowlist')
if kind == 'npm':
data = json.loads((root / 'package.json').read_text())
if data.get('private') is True:
failures.append('private npm package')
if data.get('publishConfig', {}).get('registry') != 'https://registry.npmjs.org':
failures.append('explicit official npm registry required')
if not data.get('files'):
failures.append('npm files allowlist required')
for section in ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']:
for name, value in data.get(section, {}).items():
if not isinstance(value, str) or value.startswith(('file:', 'link:', 'workspace:', 'git', 'http:', 'https:', '/', '.')):
failures.append(f'non-registry dependency: {section}/{name}')
repository = data.get('repository', {})
url = repository if isinstance(repository, str) else repository.get('url', '')
if not re.fullmatch(r'git\+https://github\.com/[\w.-]+/[\w.-]+\.git', url):
failures.append('canonical GitHub repository URL required')
package = data
else:
data = tomllib.loads((root / 'Cargo.toml').read_text())
package = data.get('package', {})
if package.get('publish') != ['crates-io']:
failures.append('publish = ["crates-io"] required')
if not package.get('repository', '').startswith('https://github.com/'):
failures.append('GitHub repository URL required')
if not re.fullmatch(r'\d+\.\d+(?:\.\d+)?', str(package.get('rust-version', ''))):
failures.append('explicit numeric Rust toolchain required')
sections = [data, data.get('workspace', {})] + list(data.get('target', {}).values())
for section in sections:
for field in ['dependencies', 'dev-dependencies', 'build-dependencies']:
for name, value in section.get(field, {}).items():
if isinstance(value, dict) and any(k in value for k in ['path', 'git', 'workspace']):
failures.append(f'local, Git, or inherited dependency: {field}/{name}')
if isinstance(value, dict) and value.get('registry', 'crates-io') != 'crates-io':
failures.append(f'alternate-registry dependency: {field}/{name}')
if data.get('patch') or data.get('replace'):
failures.append('dependency replacement must be removed for release validation')
config = root / '.cargo/config.toml'
if config.is_file():
cfg = tomllib.loads(config.read_text())
if cfg.get('source') or cfg.get('registries'):
failures.append('source replacement or alternate registry configuration')
if not package.get('name') or not re.fullmatch(r'\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?', str(package.get('version', ''))):
failures.append('explicit package name and release version required')
if not package.get('license') and not package.get('license-file'):
failures.append('license metadata required')
for name in ['LICENSE', 'NOTICE', 'README.md']:
if not (root / name).is_file():
failures.append(f'missing {name}')
return failures, package


def check_archive(path, kind, expected_name, expected_version):
failures = []
with tarfile.open(path, 'r:gz') as archive:
members = archive.getmembers()
if len(members) > 15000 or sum(m.size for m in members) > 100_000_000:
return ['archive exceeds review limits']
prefix = 'package' if kind == 'npm' else f'{expected_name}-{expected_version}'
paths = set()
for member in members:
parts = PurePosixPath(member.name).parts
if not parts or parts[0] != prefix or '..' in parts or member.issym() or member.islnk() or not (member.isfile() or member.isdir()):
failures.append('unsafe archive member')
continue
relative = '/'.join(parts[1:])
if relative in paths and member.isfile():
failures.append(f'duplicate file: {relative}')
paths.add(relative)
if any(x in {'registration', 'state', '.state', '.data', 'secrets', 'credentials', '.git', 'node_modules', 'target', '.output', '.nuxt'} for x in parts):
failures.append(f'forbidden generated or private path: {relative}')
leaf = parts[-1]
if leaf.startswith('.env') or leaf.endswith(('.tgz', '.crate', '.p12', '.pfx', '.pem', '.key', '.sqlite', '.db')):
failures.append(f'forbidden archive, credential, or runtime file: {relative}')
if member.isfile():
content = archive.extractfile(member).read()
if re.search(rb'-----BEGIN (?:[A-Z ]*PRIVATE KEY|CERTIFICATE)-----|\bgh[pousr]_[A-Za-z0-9]{30,}', content):
failures.append(f'key, certificate, or credential candidate: {relative}')
for name in ['LICENSE', 'NOTICE', 'README.md']:
if name not in paths:
failures.append(f'archive missing {name}')
manifest = 'package.json' if kind == 'npm' else 'Cargo.toml'
target = f'{prefix}/{manifest}'
if manifest not in paths:
failures.append('archive manifest missing')
else:
content = archive.extractfile(target).read().decode()
package = json.loads(content) if kind == 'npm' else tomllib.loads(content)['package']
if (package.get('name'), package.get('version')) != (expected_name, expected_version):
failures.append('archive identity differs from release manifest')
if kind == 'npm':
def entries(value):
if isinstance(value, str):
return [value]
if isinstance(value, dict):
return [x for v in value.values() for x in entries(v)]
if isinstance(value, list):
return [x for v in value for x in entries(v)]
return []
for field in ['main', 'module', 'types', 'typings', 'exports', 'bin']:
for target in entries(package.get(field)):
if not any(fnmatch.fnmatch(name, target.removeprefix('./')) for name in paths):
failures.append(f'archive missing npm entry point: {field}/{target}')
return failures


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('kind', choices=['npm', 'cargo'])
parser.add_argument('--root', default='.')
parser.add_argument('--archive')
args = parser.parse_args()
failures, package = check_manifest(args.root, args.kind)
if args.archive:
failures += check_archive(args.archive, args.kind, package['name'], package['version'])
with tarfile.open(args.archive, 'r:gz') as archive:
names = {'/'.join(PurePosixPath(m.name).parts[1:]) for m in archive.getmembers()}
for local in Path(args.root).iterdir():
if local.is_file() and (local.name.startswith('LICENSE') or local.name in ['THIRD_PARTY_NOTICES.md', 'THIRD-PARTY-NOTICES.md']):
if local.name not in names:
failures.append(f'archive omits applicable license or notice: {local.name}')
print(json.dumps({'passed': not failures, 'failures': failures}, indent=2))
raise SystemExit(bool(failures))


if __name__ == '__main__':
main()
83 changes: 83 additions & 0 deletions templates/package-publication/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Public package publication templates

These files are standalone repository assets. Copy the appropriate workflow to
`.github/workflows/publish-npm.yml` or `.github/workflows/publish-cargo.yml`, and
copy `check-public-package.py` to `scripts/check-public-package.py`. A consumer
has no runtime dependency on the template repository.

## Package contracts

For npm, declare `repository.url` as `git+https://github.com/OWNER/REPOSITORY.git`,
`publishConfig.registry` as `https://registry.npmjs.org`, and `access` as `public`.
Use an explicit `files` allowlist and pinned `packageManager: pnpm@10.x.y` or
`npm@11.5.1` (the template default when packageManager is absent).
The template requires a committed lockfile, frozen installation, a test script,
a build script when compilation is needed, and
Node 24.15.0 with npm 11.5.1 or later. Build-generated public entry points must
be in the archive. Application repositories with `private: true` remain
applications and consume published libraries; do not remove that flag just to
make this workflow pass.

For Cargo, declare the public repository URL, `rust-version`, explicit package
version, license, and `publish = ["crates-io"]`. Public release dependencies
must resolve from crates.io. Remove private registries, source replacement,
and local or Git dependencies from the release checkout. This template covers
one independently released root crate. For a multi-crate repository, release
constituent crates in dependency order with explicit package selection and
separate per-crate versions; do not flatten a private workspace into a public
crate. Publish a provider and verify its registry version before regenerating
and testing consumer lockfiles.

## Establish the delivery route

1. Confirm the npm scope or global crate name and its owner. GitHub organization
administration does not establish registry ownership. Complete the initial
legitimate package registration if the registry requires it; do not publish
an empty placeholder. The initial registration path must be reviewed using
the exact tested and licensed package.
2. Configure a trusted publisher on the registry with the exact GitHub owner,
repository, workflow filename, and environment. For npm direct publishing,
explicitly permit `npm publish`; the current stage-only default is not
sufficient. Do not add a long-lived publish token to these workflows.
3. Configure `npm-public` or `crates-io-public` as a protected GitHub environment
with a required maintainer review. Protect `main`, require the existing test
and security checks, and restrict release-tag creation. The workflow checks
that the selected release tag points to a commit reachable from main. The
default is `vVERSION`; set `REGISTRY_NPM_TAG_PREFIX=npm-v` and
`REGISTRY_CARGO_TAG_PREFIX=crate-v` in repositories publishing both ecosystems.
Multiple crates need separate package workflows and tag prefixes.
4. Complete full source and exact archive security, disclosure, license, and
third-party notice review. Set environment variable
`REGISTRY_SECURITY_APPROVED_SHA` to that reviewed commit SHA. Set repository
variable `REGISTRY_PUBLISH_ENABLED=true` only after the identity and route
are verified. Dispatch the workflow on the reviewed tag. New templates are
disabled by default and no registry publish is caused by copying them.
5. Confirm the installed package from a fresh directory with no adjacent
checkout, inherited private registry configuration, or local cache fallback.
Check package identity, version, provenance where supported, and the consumer
integration tests. Record source synchronization and package publication
separately.

The archive gate rejects unsafe tar paths, links, private/generated files,
certificates, credential candidates, and missing licensing or package identity.
It is a bounded structural check, not a complete secrets or customer-data
scanner. Run the full security tool and registered disclosure checks separately
before approving the SHA. Retain prior applicable grants and third-party
notices. Install/build scripts execute only from the reviewed source revision.

Private packages use an authenticated private registry and a separate workflow;
these public templates never authorize their publication to public registries.
A public source repository alone does not mean its current package is ready to
publish. Version numbers are not changed by the templates.

## Official references

- https://docs.npmjs.com/trusted-publishers/
- https://docs.npmjs.com/generating-provenance-statements/
- https://doc.rust-lang.org/cargo/reference/publishing.html
- https://doc.rust-lang.org/cargo/reference/registries.html
- https://crates.io/docs/trusted-publishing
- https://github.com/rust-lang/crates-io-auth-action

Action commit pins were resolved from upstream release references on 2026-10-01.
Review updates to the templates and their pins before copying a new revision.
Loading
Loading