diff --git a/.github/workflows/guard.yml b/.github/workflows/guard.yml new file mode 100644 index 0000000..94ae87c --- /dev/null +++ b/.github/workflows/guard.yml @@ -0,0 +1,30 @@ +name: guard + +on: + pull_request: {} + push: + branches: + - main + +permissions: + contents: read + +jobs: + api: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.2 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: api -> target + save-if: ${{ github.ref == 'refs/heads/main' }} + + - name: Test API + working-directory: api + run: cargo test --locked + + - name: Build API image + run: docker build ./api diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml new file mode 100644 index 0000000..42dc8a5 --- /dev/null +++ b/.github/workflows/release-beta.yml @@ -0,0 +1,59 @@ +name: release-beta + +on: + workflow_dispatch: {} + +permissions: + contents: read + packages: write + +env: + IMAGE_NAME: ghcr.io/perishcode/mini-packages-api + +jobs: + publish-api: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.2 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: api -> target + save-if: false + + - name: Test API + working-directory: api + run: cargo test --locked + + - name: Capture build timestamp + id: build_meta + run: echo "timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + + - uses: docker/setup-buildx-action@v4 + + - uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish beta API image + uses: docker/build-push-action@v7 + with: + context: ./api + push: true + build-args: | + APP_BUILD_SHA=${{ github.sha }} + APP_BUILD_TIMESTAMP=${{ steps.build_meta.outputs.timestamp }} + cache-from: type=gha,scope=mini-packages-api + cache-to: type=gha,mode=max,scope=mini-packages-api + tags: | + ${{ env.IMAGE_NAME }}:beta + ${{ env.IMAGE_NAME }}:sha-${{ github.sha }} + + - name: Best-effort public visibility + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh api -X PATCH /user/packages/container/mini-packages-api/visibility -f visibility=public || true diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml new file mode 100644 index 0000000..9d0c11a --- /dev/null +++ b/.github/workflows/release-stable.yml @@ -0,0 +1,59 @@ +name: release-stable + +on: + workflow_dispatch: {} + +permissions: + contents: read + packages: write + +env: + IMAGE_NAME: ghcr.io/perishcode/mini-packages-api + +jobs: + publish-api: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.2 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: api -> target + save-if: false + + - name: Test API + working-directory: api + run: cargo test --locked + + - name: Capture build timestamp + id: build_meta + run: echo "timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + + - uses: docker/setup-buildx-action@v4 + + - uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish stable API image + uses: docker/build-push-action@v7 + with: + context: ./api + push: true + build-args: | + APP_BUILD_SHA=${{ github.sha }} + APP_BUILD_TIMESTAMP=${{ steps.build_meta.outputs.timestamp }} + cache-from: type=gha,scope=mini-packages-api + cache-to: type=gha,mode=max,scope=mini-packages-api + tags: | + ${{ env.IMAGE_NAME }}:latest + ${{ env.IMAGE_NAME }}:sha-${{ github.sha }} + + - name: Best-effort public visibility + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh api -X PATCH /user/packages/container/mini-packages-api/visibility -f visibility=public || true diff --git a/README.md b/README.md index fad5fb0..0c91370 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,14 @@ Supported: - token create/list/get/rotate/revoke/claims - dist-tag list/add/remove with safe label validation +## API Image Releases + +The API image is published by GitHub Actions only: + +- `release-beta` pushes `ghcr.io/perishcode/mini-packages-api:beta` +- `release-stable` pushes `ghcr.io/perishcode/mini-packages-api:latest` +- both workflows also push `sha-` for pinned deployments + Out of scope for the core service: - upstream registry proxy/cache @@ -53,4 +61,3 @@ Out of scope for the core service: - web management UI - public registry features - audit database - diff --git a/api/Dockerfile b/api/Dockerfile index 7d01c71..c1d44d7 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -3,7 +3,9 @@ FROM rust:1.91-slim-bookworm AS builder WORKDIR /app COPY Cargo.toml Cargo.lock* ./ COPY src ./src -RUN cargo build --release +ARG APP_BUILD_SHA +ARG APP_BUILD_TIMESTAMP +RUN APP_BUILD_SHA="${APP_BUILD_SHA}" APP_BUILD_TIMESTAMP="${APP_BUILD_TIMESTAMP}" cargo build --release FROM debian:bookworm-slim diff --git a/e2e/scripts/npm-pnpm-smoke.mjs b/e2e/scripts/npm-pnpm-smoke.mjs index a6f98a8..f098e15 100644 --- a/e2e/scripts/npm-pnpm-smoke.mjs +++ b/e2e/scripts/npm-pnpm-smoke.mjs @@ -11,11 +11,13 @@ const version = `0.0.0-smoke.${Date.now()}`; const root = await mkdtemp(path.join(tmpdir(), 'mini-packages-e2e-')); try { + await verifyPing(); await verifyTokenLifecycle(); const { token } = await createToken(`e2e-${Date.now()}`); await writeNpmrc(root, token); await publishAndInstallWithNpm(token); await publishAndInstallWithPnpm(token); + await verifyLocalLinkWorkflows(token); } finally { if (!process.env.E2E_KEEP_TMP) { await rm(root, { recursive: true, force: true }); @@ -24,6 +26,13 @@ try { } } +async function verifyPing() { + const response = await fetch(`${baseUrl}/-/ping`); + if (!response.ok) { + throw new Error(`ping failed: ${response.status} ${await response.text()}`); + } +} + async function verifyTokenLifecycle() { const created = await createToken(`lifecycle-${Date.now()}`); await expectWhoami(created.token, 200); @@ -99,10 +108,16 @@ async function writeNpmrc(dir, token) { async function publishAndInstallWithNpm(token) { const packageName = `${scope}/npm-pkg`; const packageDir = await createPackage('npm-pkg', packageName, token); - await run('npm', ['publish', '--registry', baseUrl, '--tag', 'npm-smoke', '--access', 'restricted'], { + await run('npm', ['publish', '--registry', baseUrl, '--tag', 'beta', '--access', 'restricted'], { cwd: packageDir, env: authEnv(token) }); + await expectNpmDistTag(packageName, 'beta', version, token); + await run('npm', ['dist-tag', 'add', `${packageName}@${version}`, 'latest', '--registry', baseUrl], { + cwd: packageDir, + env: authEnv(token) + }); + await expectNpmDistTag(packageName, 'latest', version, token); await run('npm', ['dist-tag', 'ls', packageName, '--registry', baseUrl], { cwd: packageDir, env: authEnv(token) @@ -120,7 +135,11 @@ async function publishAndInstallWithNpm(token) { await mkdir(consumer, { recursive: true }); await writeFile(path.join(consumer, 'package.json'), '{"private":true}\n'); await writeNpmrc(consumer, token); - await run('npm', ['install', `${packageName}@${version}`, '--registry', baseUrl], { + await run('npm', ['install', `${packageName}@beta`, '--registry', baseUrl], { + cwd: consumer, + env: authEnv(token) + }); + await run('npm', ['install', `${packageName}@latest`, '--registry', baseUrl], { cwd: consumer, env: authEnv(token) }); @@ -129,21 +148,46 @@ async function publishAndInstallWithNpm(token) { async function publishAndInstallWithPnpm(token) { const packageName = `${scope}/pnpm-pkg`; const packageDir = await createPackage('pnpm-pkg', packageName, token); - await run('pnpm', ['publish', '--registry', baseUrl, '--tag', 'pnpm-smoke', '--no-git-checks'], { + await run('pnpm', ['publish', '--registry', baseUrl, '--tag', 'beta', '--no-git-checks'], { cwd: packageDir, env: authEnv(token) }); + await expectNpmDistTag(packageName, 'beta', version, token); const consumer = path.join(root, 'pnpm-consumer'); await mkdir(consumer, { recursive: true }); await writeFile(path.join(consumer, 'package.json'), '{"private":true}\n'); await writeNpmrc(consumer, token); - await run('pnpm', ['add', `${packageName}@${version}`, '--registry', baseUrl], { + await run('pnpm', ['add', `${packageName}@beta`, '--registry', baseUrl], { cwd: consumer, env: authEnv(token) }); } +async function verifyLocalLinkWorkflows(token) { + const npmPackageName = `${scope}/npm-link-pkg`; + const npmPackageDir = await createPackage('npm-link-pkg', npmPackageName, token); + const npmConsumer = path.join(root, 'npm-link-consumer'); + await mkdir(npmConsumer, { recursive: true }); + await writeFile(path.join(npmConsumer, 'package.json'), '{"private":true,"type":"module"}\n'); + await run('npm', ['link', npmPackageDir], { + cwd: npmConsumer, + env: authEnv(token) + }); + await expectImportValue(npmConsumer, npmPackageName, `${npmPackageName}@${version}`, token); + + const pnpmPackageName = `${scope}/pnpm-link-pkg`; + const pnpmPackageDir = await createPackage('pnpm-link-pkg', pnpmPackageName, token); + const pnpmConsumer = path.join(root, 'pnpm-link-consumer'); + await mkdir(pnpmConsumer, { recursive: true }); + await writeFile(path.join(pnpmConsumer, 'package.json'), '{"private":true,"type":"module"}\n'); + await run('pnpm', ['add', `link:${pnpmPackageDir}`], { + cwd: pnpmConsumer, + env: authEnv(token) + }); + await expectImportValue(pnpmConsumer, pnpmPackageName, `${pnpmPackageName}@${version}`, token); +} + async function createPackage(dirname, packageName, token) { const packageDir = path.join(root, dirname); await mkdir(packageDir, { recursive: true }); @@ -155,7 +199,9 @@ async function createPackage(dirname, packageName, token) { name: packageName, version, description: 'mini packages smoke package', + type: 'module', main: 'index.js', + exports: './index.js', files: ['index.js'] }, null, @@ -166,6 +212,36 @@ async function createPackage(dirname, packageName, token) { return packageDir; } +async function expectNpmDistTag(packageName, tag, expected, token) { + const stdout = await runCapture('npm', ['dist-tag', 'ls', packageName, '--registry', baseUrl], { + cwd: root, + env: authEnv(token) + }); + const actual = Object.fromEntries( + stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => line.split(/:\s+/, 2)) + )[tag]; + if (actual !== expected) { + throw new Error(`${packageName} dist-tag ${tag} expected ${expected}, got ${actual}`); + } +} + +async function expectImportValue(cwd, packageName, expected, token) { + const script = [ + `const mod = await import(${JSON.stringify(packageName)});`, + `if (mod.value !== ${JSON.stringify(expected)}) {`, + ` throw new Error(${JSON.stringify(`unexpected linked value for ${packageName}`)} + ': ' + mod.value);`, + '}' + ].join('\n'); + await run('node', ['--input-type=module', '-e', script], { + cwd, + env: authEnv(token) + }); +} + function authEnv(token) { void token; return { @@ -177,6 +253,7 @@ function authEnv(token) { TMP: process.env.TMP, COREPACK_HOME: process.env.COREPACK_HOME, PNPM_HOME: process.env.PNPM_HOME, + CI: '1', NPM_CONFIG_USERCONFIG: path.join(root, '.npmrc'), npm_config_userconfig: path.join(root, '.npmrc'), npm_config_registry: baseUrl @@ -199,3 +276,28 @@ function run(command, args, options = {}) { }); }); } + +function runCapture(command, args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: ['ignore', 'pipe', 'pipe'], + cwd: options.cwd || root, + env: options.env || process.env + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + child.on('exit', (code) => { + if (code === 0) { + resolve(stdout.trim()); + } else { + reject(new Error(`${command} ${args.join(' ')} failed with exit ${code}\n${stderr || stdout}`)); + } + }); + }); +}