diff --git a/.github/workflows/publish-cargo.yml b/.github/workflows/publish-cargo.yml new file mode 100644 index 0000000..02230ae --- /dev/null +++ b/.github/workflows/publish-cargo.yml @@ -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 diff --git a/Cargo.toml b/Cargo.toml index b5ff512..4ccaa12 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index 3d3502e..5322ae3 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/registry-publication.json b/registry-publication.json new file mode 100644 index 0000000..d904f37 --- /dev/null +++ b/registry-publication.json @@ -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 +} diff --git a/scripts/check-public-package.py b/scripts/check-public-package.py new file mode 100644 index 0000000..2a06308 --- /dev/null +++ b/scripts/check-public-package.py @@ -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() diff --git a/templates/package-publication/README.md b/templates/package-publication/README.md new file mode 100644 index 0000000..b8aced6 --- /dev/null +++ b/templates/package-publication/README.md @@ -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. diff --git a/templates/package-publication/check-public-package.py b/templates/package-publication/check-public-package.py new file mode 100644 index 0000000..2a06308 --- /dev/null +++ b/templates/package-publication/check-public-package.py @@ -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() diff --git a/templates/package-publication/publish-cargo.yml b/templates/package-publication/publish-cargo.yml new file mode 100644 index 0000000..02230ae --- /dev/null +++ b/templates/package-publication/publish-cargo.yml @@ -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 diff --git a/templates/package-publication/publish-npm.yml b/templates/package-publication/publish-npm.yml new file mode 100644 index 0000000..cfc504f --- /dev/null +++ b/templates/package-publication/publish-npm.yml @@ -0,0 +1,64 @@ +name: Publish npm package +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: npm-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 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '24.15.0' + - name: Install the reviewed package manager + run: | + npm install --global npm@11.5.1 + MANAGER=$(node -e 'const p=require("./package.json"); const m=p.packageManager || "npm@11.5.1"; if(!/^(?:pnpm@10\.\d+\.\d+|npm@11\.5\.1)$/.test(m)) throw Error("Pinned pnpm 10 or npm 11.5.1 required"); process.stdout.write(m)') + npm install --global "$MANAGER" + echo "PACKAGE_MANAGER=${MANAGER%%@*}" >> "$GITHUB_ENV" + - name: Check registry metadata and release tag + env: + RELEASE_TAG_PREFIX: ${{ vars.REGISTRY_NPM_TAG_PREFIX || 'v' }} + run: | + python3 scripts/check-public-package.py npm + VERSION=$(node -p 'require("./package.json").version') + test "$GITHUB_REF_TYPE" = tag + test "$GITHUB_REF_NAME" = "${RELEASE_TAG_PREFIX}${VERSION}" + - name: Build, test, and pack the frozen source + run: | + if test "$PACKAGE_MANAGER" = pnpm; then + test -f pnpm-lock.yaml + pnpm install --frozen-lockfile + else + test -f package-lock.json + npm ci + fi + "$PACKAGE_MANAGER" test + "$PACKAGE_MANAGER" run --if-present build + mkdir -p "$RUNNER_TEMP/package-release" + npm pack --json --pack-destination "$RUNNER_TEMP/package-release" > "$RUNNER_TEMP/package-pack.json" + ARCHIVE=$(node -e 'const p=require(process.env.RUNNER_TEMP+"/package-pack.json");if(p.length!==1)throw Error("One archive required");process.stdout.write(p[0].filename)') + python3 scripts/check-public-package.py npm --archive "$RUNNER_TEMP/package-release/$ARCHIVE" + - name: Publish the inspected archive through OIDC + run: | + ARCHIVE=$(node -e 'const p=require(process.env.RUNNER_TEMP+"/package-pack.json");process.stdout.write(p[0].filename)') + npm publish "$RUNNER_TEMP/package-release/$ARCHIVE" --registry=https://registry.npmjs.org --access public --provenance --ignore-scripts diff --git a/templates/package-publication/template-policy.json b/templates/package-publication/template-policy.json new file mode 100644 index 0000000..b2e7be5 --- /dev/null +++ b/templates/package-publication/template-policy.json @@ -0,0 +1,24 @@ +{ + "schema": "zixcel://github/package-publication-template/v1", + "status": "prepared-locally-not-activated", + "public_registries": { + "npm": "https://registry.npmjs.org", + "cargo": "https://crates.io" + }, + "publication_trigger": "workflow_dispatch on reviewed vVERSION tag", + "default_enabled": false, + "required_variables": [ + "REGISTRY_PUBLISH_ENABLED", + "REGISTRY_SECURITY_APPROVED_SHA" + ], + "required_environments": [ + "npm-public", + "crates-io-public" + ], + "identity": "exact GitHub owner, repository, workflow filename, environment configured by the registry owner", + "local_dependencies": "No file/link/workspace archives, outside paths, source replacement, or alternate registries in public release dependencies. Resolve and publish foundations first.", + "private_packages": "Never sent to npm public or crates.io by these templates.", + "bootstrap": "Name ownership and initial package registration must be established independently; do not create placeholder releases.", + "npm_allowed_actions": "Enable npm publish explicitly in the trusted publisher configuration; current stage-only default cannot run this direct-publish template.", + "source_security": "Complete repository and exact package archive security/licensing review before approving the source SHA. Basic archive guard is not a complete secret or confidential-data scanner." +} diff --git a/templates/package-publication/test_public_package_gate.py b/templates/package-publication/test_public_package_gate.py new file mode 100644 index 0000000..3643e79 --- /dev/null +++ b/templates/package-publication/test_public_package_gate.py @@ -0,0 +1,108 @@ +import importlib.util +import io +import json +import tarfile +import tempfile +import unittest +from pathlib import Path + +spec = importlib.util.spec_from_file_location('gate', Path(__file__).with_name('check-public-package.py')) +gate = importlib.util.module_from_spec(spec) +spec.loader.exec_module(gate) + + +class PublicPackageGateTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + self.pkg = {'name': '@example/module', 'version': '1.0.0', 'license': 'Apache-2.0', + 'files': ['index.js', 'LICENSE', 'NOTICE', 'README.md'], + 'main': './index.js', 'repository': {'url': 'git+https://github.com/example/module.git'}, + 'publishConfig': {'registry': 'https://registry.npmjs.org'}} + self.write_manifest() + for name in ['LICENSE', 'NOTICE', 'README.md']: + (self.root / name).write_text('Example fixture') + + def tearDown(self): + self.temp.cleanup() + + def write_manifest(self): + (self.root / 'package.json').write_text(json.dumps(self.pkg)) + + def archive(self, extra=None, omit=None): + entries = {'package/package.json': json.dumps(self.pkg).encode(), + **{f'package/{n}': b'fixture' for n in ['LICENSE', 'NOTICE', 'README.md', 'index.js']}} + entries.update(extra or {}) + for n in omit or []: + del entries[n] + archive = self.root / 'example.tgz' + with tarfile.open(archive, 'w:gz') as target: + for name, value in entries.items(): + item = tarfile.TarInfo(name) + if isinstance(value, tarfile.TarInfo): + target.addfile(value) + else: + item.size = len(value) + target.addfile(item, io.BytesIO(value)) + return archive + + def inspect(self, archive): + return gate.check_archive(archive, 'npm', self.pkg['name'], self.pkg['version']) + + def test_valid_manifest_and_archive(self): + self.assertEqual(gate.check_manifest(self.root, 'npm')[0], []) + self.assertEqual(self.inspect(self.archive()), []) + + def test_private_package_or_local_dependency_rejected(self): + self.pkg['private'] = True + self.pkg['dependencies'] = {'@example/peer': 'file:../peer.tgz'} + self.write_manifest() + failures = gate.check_manifest(self.root, 'npm')[0] + self.assertIn('private npm package', failures) + self.assertTrue(any('non-registry dependency' in x for x in failures)) + + def test_missing_built_entry_and_license_rejected(self): + failures = self.inspect(self.archive(omit=['package/index.js', 'package/LICENSE'])) + self.assertTrue(any('entry point' in x for x in failures)) + self.assertIn('archive missing LICENSE', failures) + + def test_private_state_and_credential_payload_rejected(self): + failures = self.inspect(self.archive({'package/registration/item.json': b'{}', + 'package/example.txt': b'-----BEGIN' + b' PRIVATE KEY-----'})) + self.assertTrue(any('private path' in x for x in failures)) + self.assertTrue(any('credential candidate' in x for x in failures)) + + def test_traversal_and_symlink_rejected_without_extraction(self): + link = tarfile.TarInfo('package/link') + link.type = tarfile.SYMTYPE + link.linkname = '../../outside' + failures = self.inspect(self.archive({'../outside': b'bad', 'package/link': link})) + self.assertEqual(failures.count('unsafe archive member'), 2) + self.assertFalse((self.root.parent / 'outside').exists()) + + def test_public_catalog_cannot_include_private_entries(self): + (self.root / 'public-package-policy.json').write_text(json.dumps({'repository_ids': ['example-public']})) + (self.root / 'catalog-v2.json').write_text(json.dumps({'entries': [{'repository_id': 'example-private'}]})) + self.assertTrue(any('public allowlist' in x for x in gate.check_manifest(self.root, 'npm')[0])) + + def test_cargo_private_registry_and_source_replacement_rejected(self): + (self.root / 'Cargo.toml').write_text('''[package] +name = "example-crate" +version = "1.0.0" +rust-version = "1.97" +license = "Apache-2.0" +repository = "https://github.com/example/crate" +publish = ["crates-io"] +[dependencies] +example = { version = "1.0.0", registry = "private", path = "../example" } +''') + (self.root / '.cargo').mkdir() + (self.root / '.cargo/config.toml').write_text('[source.crates-io]\nreplace-with = "local"\n') + failures = gate.check_manifest(self.root, 'cargo')[0] + self.assertTrue(any('alternate-registry dependency' in x for x in failures)) + self.assertTrue(any('local, Git' in x for x in failures)) + self.assertTrue(any('source replacement' in x for x in failures)) + + +if __name__ == '__main__': + unittest.main()